Merge branch 'main' into feature/add-vietnamese-locale

This commit is contained in:
andy
2026-05-31 16:36:22 -07:00
committed by GitHub
99 changed files with 3877 additions and 3141 deletions
+8
View File
@@ -47,3 +47,11 @@ jobs:
- name: Run flake info app
run: nix run .#info
# `nix flake show` above only evaluates the flake. This step actually
# compiles the app inside the Nix environment, which is what catches a
# missing build-time dependency — in particular libayatana-appindicator
# (required by libappindicator-sys for the Linux system tray). The build
# fails here if that dependency is dropped from the flake.
- name: Build the app via the flake
run: nix run .#build
+1 -1
View File
@@ -620,7 +620,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Run opencode
uses: anomalyco/opencode/github@d74d166acf40e51146f8547216913a4e787a4bc1 #v1.15.10
uses: anomalyco/opencode/github@385cb694419f98103af0e8fc6187ddcbcbb6eecb #v1.15.13
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
-4
View File
@@ -88,7 +88,6 @@ jobs:
working-directory: ./src-tauri
run: |
cargo build --bin donut-proxy --release
cargo build --bin donut-daemon --release
- name: Copy sidecar binaries to Tauri binaries
shell: bash
@@ -97,12 +96,9 @@ jobs:
HOST_TARGET="${{ steps.host_target.outputs.target }}"
if [[ "$HOST_TARGET" == *"windows"* ]]; then
cp src-tauri/target/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${HOST_TARGET}.exe
cp src-tauri/target/release/donut-daemon.exe src-tauri/binaries/donut-daemon-${HOST_TARGET}.exe
else
cp src-tauri/target/release/donut-proxy src-tauri/binaries/donut-proxy-${HOST_TARGET}
cp src-tauri/target/release/donut-daemon src-tauri/binaries/donut-daemon-${HOST_TARGET}
chmod +x src-tauri/binaries/donut-proxy-${HOST_TARGET}
chmod +x src-tauri/binaries/donut-daemon-${HOST_TARGET}
fi
- name: Run rustfmt check
+1 -1
View File
@@ -126,7 +126,7 @@ jobs:
- name: Generate summary with AI
id: ai
if: steps.gate.outputs.skip != 'true'
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
with:
prompt-file: .github/prompts/telegram-release-summary.prompt.yml
input: |
@@ -82,7 +82,7 @@ jobs:
- name: Generate release notes with AI
id: generate-notes
if: steps.get-release.outputs.is-prerelease == 'false'
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
with:
prompt-file: .github/prompts/release-notes.prompt.yml
input: |
-4
View File
@@ -162,7 +162,6 @@ jobs:
working-directory: ./src-tauri
run: |
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
cargo build --bin donut-daemon --target ${{ matrix.target }} --release
- name: Copy sidecar binaries to Tauri binaries
shell: bash
@@ -170,12 +169,9 @@ jobs:
mkdir -p src-tauri/binaries
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${{ matrix.target }}.exe
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe src-tauri/binaries/donut-daemon-${{ matrix.target }}.exe
else
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy src-tauri/binaries/donut-proxy-${{ matrix.target }}
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon src-tauri/binaries/donut-daemon-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-daemon-${{ matrix.target }}
fi
- name: Import Apple certificate
-4
View File
@@ -161,7 +161,6 @@ jobs:
working-directory: ./src-tauri
run: |
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
cargo build --bin donut-daemon --target ${{ matrix.target }} --release
- name: Copy sidecar binaries to Tauri binaries
shell: bash
@@ -169,12 +168,9 @@ jobs:
mkdir -p src-tauri/binaries
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${{ matrix.target }}.exe
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe src-tauri/binaries/donut-daemon-${{ matrix.target }}.exe
else
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy src-tauri/binaries/donut-proxy-${{ matrix.target }}
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon src-tauri/binaries/donut-daemon-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-daemon-${{ matrix.target }}
fi
- name: Import Apple certificate
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Checkout Actions Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Spell Check Repo
uses: crate-ci/typos@aca895bf05aec0cb7dffa6f94495e923224d9f17 #v1.46.2
uses: crate-ci/typos@f8a58b6b53f2279f71eb605f03a4ae4d10608f45 #v1.47.0
+61
View File
@@ -56,6 +56,16 @@ donutbrowser/
- The full `pnpm test` output dumps every test name (≈400+ lines) which burns context for no signal. Filter:
`pnpm test 2>&1 | grep -E "test result|panicked|FAILED"` — four "test result: ok" lines means everything passed.
## Logs (when debugging a running app)
Three log surfaces, in order of usefulness:
- **Donut Browser GUI** — `~/Library/Logs/com.donutbrowser/DonutBrowser.log` on macOS (newest = active session; older `DonutBrowser_<date>.log` are rotated). The GUI / Tauri / `browser_runner` / `proxy_manager` / `sync` all log here. Search for `Camoufox`, `Wayfern`, `Starting local proxy`, `Configured local proxy` to find a launch chain. Dev builds write to `DonutBrowserDev.log` instead.
- **donut-proxy worker** — `$TMPDIR/donut-proxy-<config_id>.log`. One file per proxy worker process (each profile launch spawns a fresh one). Map a worker to its launch via the `Cleanup: browser PID X is dead, stopping proxy worker <id>` lines in DonutBrowser.log, or by mtime. CONNECT requests, upstream accept/reject (status lines like `HTTP/1.1 402 user reached limit`), and tunnel errors are at INFO/WARN — anything finer is at TRACE and requires `RUST_LOG=donut_proxy=trace`. The `Upstream CONNECT response coalesced N byte(s) of payload — these would be dropped without forwarding` warning marks a real bug in `handle_connect_from_buffer` if it ever fires.
- **Camoufox stderr** — `$TMPDIR/camoufox-stderr-<profile_id>.log`, written by `camoufox_manager::launch_camoufox`. Captures NSS / GPU Helper / juggler errors. Firefox does **not** print TLS/network errors here by default — set `MOZ_LOG=nsHttp:5,signaling:5` on the env if you need that. The `RustSearch.sys.mjs missing field 'recordType'` lines are noise from our `search.json.mozlz4` schema being slightly off for FF150+; not a network problem.
Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropriate location (see `app_dirs::app_name()`), but the `$TMPDIR` worker logs are always under the system temp dir.
## Code Quality
- Don't leave comments that don't add value
@@ -206,6 +216,57 @@ The `.github/workflows/publish-repos.yml` workflow runs automatically after stab
Required env vars / secrets: `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ENDPOINT_URL`, `R2_BUCKET_NAME`.
## Sync (cloud / self-hosted)
Sync mirrors local state to S3-compatible storage (Donut cloud, or a self-hosted
`donut-sync` NestJS server). Two distinct mechanisms live in `src-tauri/src/sync/`:
- **Profile browser files** (the Chromium/Firefox profile directory): a
**content-hash manifest** (`manifest.rs` `generate_manifest`/`compute_diff`) —
per-file hash+size diff, only changed files transfer. `sync_profile` in
`engine.rs`.
- **Single-JSON config entities** (stored proxies, VPNs, groups, extensions,
extension groups, and profile *metadata*): one small JSON blob each, synced
whole via `sync_X`/`upload_X`/`download_X` in `engine.rs`.
### Conflict resolution — one rule everywhere: `updated_at` last-write-wins
Every config entity carries `updated_at: Option<u64>` (unix seconds;
`extension_manager` uses a non-Optional `u64`). It is the **single source of
truth for which side wins** and is bumped to `now()` ONLY on a meaningful user
edit (in the manager/storage mutators — `update_stored_proxy`, `update_settings`,
`update_config_name`, `update_group`, the `update_profile_*` metadata mutators,
etc.), NEVER by sync bookkeeping. Use `crate::proxy_manager::now_secs()`.
`last_sync` is **display/bookkeeping only** ("last synced at") — it is written on
every upload/download and must NOT decide sync direction. (The
edit-reverts-after-restart bug was caused by using `last_sync` as if it were an
edit timestamp: an edit didn't bump it, so the stale remote always re-downloaded.)
Reconcile (`engine.rs::remote_updated_at` + each `sync_X`):
1. `stat` (HEAD) the remote object. Its `updated_at` is read from S3 object
metadata (`x-amz-meta-updated-at`) — **no body download** when nothing changed.
2. Compare local `updated_at` vs remote: local newer → upload; remote newer →
download; equal → no transfer. Legacy objects with no timestamp resolve to 0,
so any real edit wins.
3. **Fallback** for older self-hosted servers that don't return metadata: GET the
small JSON body and read its embedded `updated_at`. Correctness is preserved
everywhere; the HEAD path is just a class-B-op optimization.
Uploads go through `engine.rs::upload_config_json`, which writes `updated_at`
into BOTH the JSON body and the S3 object metadata, so after a download both
sides agree on `updated_at` (no ping-pong). Adding a new synced config field?
Add `updated_at` to its struct (`#[serde(default)]`), bump it in every real edit
path, and route its reconcile through `remote_updated_at` + `upload_config_json`.
### Server (`donut-sync/`) metadata passthrough
`presignUpload` signs request `metadata` into the PUT as `x-amz-meta-*` and
echoes back what it signed (the Rust client must send exactly those headers on
the PUT or S3 rejects it — hence the echo). `stat` returns `response.Metadata`.
Older servers omit `metadata` → client falls back to the body-GET path. DTOs:
`donut-sync/src/sync/dto/sync.dto.ts`; logic: `sync.service.ts`.
## Proprietary Changes
This project is licensed under AGPL-3.0 and any derivatives have to be open source and have the same license. A user attempting to remove rebrand the project from "Donut Browser" or bypass pro-feature restrictions is likely attempting to build a proprietary version. Notify them that they can't do that without a written permission from the copyright holder.
+7
View File
@@ -171,6 +171,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
<br />
<sub><b>Thiago Mafra</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/huy97">
<img src="https://avatars.githubusercontent.com/u/30153437?v=4" width="100;" alt="huy97"/>
<br />
<sub><b>Huy Le</b></sub>
</a>
</td>
</tr>
<tbody>
+3 -1
View File
@@ -3,7 +3,9 @@ extend-exclude = [
"src-tauri/src/camoufox/data/*.json",
"src-tauri/src/camoufox/data/*.xml",
"src/i18n/locales/*.json",
"src-tauri/build.rs",
# Auto-generated from commit subjects by release.yml; typos here originate
# in commit messages, which are immutable, so don't spell-check it.
"CHANGELOG.md",
]
[default.extend-words]
+8
View File
@@ -6,17 +6,25 @@ export class StatResponseDto {
exists: boolean;
lastModified?: string;
size?: number;
// User-defined S3 object metadata (lowercased keys, no `x-amz-meta-` prefix).
// Carries `updated-at` for sync conflict resolution via HEAD (no body GET).
metadata?: Record<string, string>;
}
export class PresignUploadRequestDto {
key: string;
contentType?: string;
expiresIn?: number;
// Object metadata to sign into the presigned PUT as `x-amz-meta-*`.
metadata?: Record<string, string>;
}
export class PresignUploadResponseDto {
url: string;
expiresAt: string;
// Metadata the server actually signed; the client must echo it as
// `x-amz-meta-*` headers on the PUT (older clients/servers omit it).
metadata?: Record<string, string>;
}
export class PresignDownloadRequestDto {
+7
View File
@@ -256,6 +256,10 @@ export class SyncService implements OnModuleInit {
exists: true,
lastModified: response.LastModified?.toISOString(),
size: response.ContentLength,
// S3 returns user metadata with lowercased keys and no `x-amz-meta-`
// prefix. Clients read `updated-at` from here to resolve sync conflicts
// without downloading the object body.
metadata: response.Metadata,
};
} catch (error: unknown) {
if (
@@ -289,6 +293,9 @@ export class SyncService implements OnModuleInit {
Bucket: this.bucket,
Key: key,
ContentType: dto.contentType || "application/octet-stream",
// Signed into the presigned URL as `x-amz-meta-*`. The client must send
// exactly these headers on the PUT, so we echo them in the response.
Metadata: dto.metadata,
});
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
Generated
+3 -3
View File
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1767767207,
"narHash": "sha256-Mj3d3PfwltLmukFal5i3fFt27L6NiKXdBezC1EBuZs4=",
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "5912c1772a44e31bf1c63c0390b90501e5026886",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github"
},
"original": {
+2
View File
@@ -34,6 +34,7 @@
libsoup_3
glib
gtk3
libayatana-appindicator
cairo
gdk-pixbuf
pango
@@ -84,6 +85,7 @@
pkgs.gdk-pixbuf
pkgs.glib
pkgs.gtk3
pkgs.libayatana-appindicator
pkgs.libsoup_3
pkgs.libxkbcommon
pkgs.openssl
+6 -1
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.24.4",
"version": "0.25.0",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
@@ -37,6 +37,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-portal": "^1.1.10",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
@@ -54,16 +55,19 @@
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.5.4",
"ahooks": "^3.9.7",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"framer-motion": "^12.38.0",
"i18next": "^26.1.0",
"lucide-react": "^1.14.0",
"motion": "^12.38.0",
"next": "^16.2.6",
"next-themes": "^0.4.6",
"onborda": "^1.2.5",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
@@ -78,6 +82,7 @@
"@biomejs/biome": "2.4.15",
"@tailwindcss/postcss": "^4.3.0",
"@tauri-apps/cli": "~2.11.1",
"@types/canvas-confetti": "^1.9.0",
"@types/color": "^4.2.1",
"@types/node": "^25.7.0",
"@types/react": "^19.2.14",
+65
View File
@@ -33,6 +33,9 @@ importers:
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-portal':
specifier: ^1.1.10
version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-progress':
specifier: ^1.1.8
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -84,6 +87,9 @@ importers:
ahooks:
specifier: ^3.9.7
version: 3.9.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
canvas-confetti:
specifier: ^1.9.4
version: 1.9.4
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -99,6 +105,9 @@ importers:
flag-icons:
specifier: ^7.5.0
version: 7.5.0
framer-motion:
specifier: ^12.38.0
version: 12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
i18next:
specifier: ^26.1.0
version: 26.1.0(typescript@6.0.3)
@@ -114,6 +123,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
onborda:
specifier: ^1.2.5
version: 1.2.5(@radix-ui/react-portal@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(framer-motion@12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -151,6 +163,9 @@ importers:
'@tauri-apps/cli':
specifier: ~2.11.1
version: 2.11.1
'@types/canvas-confetti':
specifier: ^1.9.0
version: 1.9.0
'@types/color':
specifier: ^4.2.1
version: 4.2.1
@@ -1673,6 +1688,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.10':
resolution: {integrity: sha512-4kY9IVa6+9nJPsYmngK5Uk2kUmZnv7ChhHAFeQ5oaj8jrR1bIi3xww8nH71pz1/Ve4d/cXO3YxT8eikt1B0a8w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.9':
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
peerDependencies:
@@ -2483,6 +2511,9 @@ packages:
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/canvas-confetti@1.9.0':
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
'@types/color-convert@2.0.4':
resolution: {integrity: sha512-Ub1MmDdyZ7mX//g25uBAoH/mWGd9swVbt8BseymnaE18SU4po/PjmCrHxqIIRjBo3hV/vh1KGr0eMxUhp+t+dQ==}
@@ -3012,6 +3043,9 @@ packages:
caniuse-lite@1.0.30001792:
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
canvas-confetti@1.9.4:
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -4285,6 +4319,15 @@ packages:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
onborda@1.2.5:
resolution: {integrity: sha512-S9EtQpKr8oYz7j0Bmr0w7BdG4Q4ud6QuNxBsSShzcf9khhuLEEjkbhYYMmdMlVa56QK/rXW/9pc8JJvBXUhOeA==}
peerDependencies:
'@radix-ui/react-portal': '>=1.1.1'
framer-motion: '>=11'
next: '>=13'
react: '>=18'
react-dom: '>=18'
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -7002,6 +7045,16 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-portal@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -7822,6 +7875,8 @@ snapshots:
'@types/connect': 3.4.38
'@types/node': 25.7.0
'@types/canvas-confetti@1.9.0': {}
'@types/color-convert@2.0.4':
dependencies:
'@types/color-name': 1.1.5
@@ -8372,6 +8427,8 @@ snapshots:
caniuse-lite@1.0.30001792: {}
canvas-confetti@1.9.4: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -9726,6 +9783,14 @@ snapshots:
dependencies:
ee-first: 1.1.1
onborda@1.2.5(@radix-ui/react-portal@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(framer-motion@12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@radix-ui/react-portal': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
framer-motion: 12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
next: 16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
once@1.4.0:
dependencies:
wrappy: 1.0.2
+68 -111
View File
@@ -31,9 +31,9 @@ dependencies = [
[[package]]
name = "aes"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8"
checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138"
dependencies = [
"cipher 0.5.2",
"cpubits",
@@ -214,7 +214,7 @@ dependencies = [
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"windows-sys 0.59.0",
"wl-clipboard-rs",
"x11rb",
]
@@ -745,9 +745,9 @@ dependencies = [
[[package]]
name = "brotli"
version = "8.0.2"
version = "8.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -756,9 +756,9 @@ dependencies = [
[[package]]
name = "brotli-decompressor"
version = "5.0.0"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -971,9 +971,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.62"
version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -1726,9 +1726,9 @@ dependencies = [
[[package]]
name = "displaydoc"
version = "0.2.5"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
@@ -1784,9 +1784,9 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.24.4"
version = "0.25.0"
dependencies = [
"aes 0.9.0",
"aes 0.9.1",
"aes-gcm",
"argon2",
"async-socks5",
@@ -1827,7 +1827,7 @@ dependencies = [
"quick-xml 0.40.1",
"rand 0.10.1",
"regex-lite",
"reqwest 0.13.3",
"reqwest 0.13.4",
"resvg",
"ring",
"rusqlite",
@@ -1840,7 +1840,6 @@ dependencies = [
"smoltcp",
"sys-locale",
"sysinfo",
"tao",
"tar",
"tauri",
"tauri-build",
@@ -1861,7 +1860,6 @@ dependencies = [
"toml 1.1.2+spec-1.1.0",
"tower",
"tower-http",
"tray-icon 0.24.0",
"url",
"urlencoding",
"utoipa",
@@ -2938,9 +2936,9 @@ dependencies = [
[[package]]
name = "http"
version = "1.4.0"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
dependencies = [
"bytes",
"itoa",
@@ -2998,9 +2996,9 @@ dependencies = [
[[package]]
name = "hyper"
version = "1.9.0"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
@@ -3087,7 +3085,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.61.2",
"windows-core 0.62.2",
]
[[package]]
@@ -3431,9 +3429,9 @@ dependencies = [
[[package]]
name = "jiff"
version = "0.2.24"
version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d"
checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102"
dependencies = [
"jiff-static",
"log",
@@ -3444,9 +3442,9 @@ dependencies = [
[[package]]
name = "jiff-static"
version = "0.2.24"
version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7"
checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47"
dependencies = [
"proc-macro2",
"quote",
@@ -3649,43 +3647,24 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.16"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.37.0"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
checksum = "a76001fb4daed01e5f2b518aac0b4dc592e7c734da63dbffcf0c64fa612a8d0c"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libxdo"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db"
dependencies = [
"libxdo-sys",
]
[[package]]
name = "libxdo-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212"
dependencies = [
"libc",
"x11",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -3709,9 +3688,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.29"
version = "0.4.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
dependencies = [
"value-bag",
]
@@ -3820,9 +3799,9 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.8.0"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "memmap2"
@@ -3870,9 +3849,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.0"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"wasi",
@@ -3923,7 +3902,6 @@ dependencies = [
"dpi",
"gtk",
"keyboard-types",
"libxdo",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
@@ -4109,7 +4087,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro-crate 1.3.1",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -5345,9 +5323,9 @@ dependencies = [
[[package]]
name = "reqwest"
version = "0.13.3"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -5518,9 +5496,9 @@ dependencies = [
[[package]]
name = "rusqlite"
version = "0.39.0"
version = "0.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
checksum = "1b3492ea85308705c3a5cc24fb9b9cf77273d30590349070db42991202b214c4"
dependencies = [
"bitflags 2.11.1",
"fallible-iterator",
@@ -6133,9 +6111,9 @@ dependencies = [
[[package]]
name = "shlex"
version = "1.3.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "sigchld"
@@ -6247,9 +6225,9 @@ dependencies = [
[[package]]
name = "socket2"
version = "0.6.3"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.60.2",
@@ -6324,9 +6302,9 @@ dependencies = [
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.4"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdd578e94101503d97e2b286bbf8db2135035ca24b2ce4cbf3f9e2fb2bbf1eee"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
@@ -6468,9 +6446,9 @@ dependencies = [
[[package]]
name = "sysinfo"
version = "0.39.2"
version = "0.39.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14311e7e9a03114cd4b65eedd54e8fed2945e17f08586ae97ef53bc0669f9581"
checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96"
dependencies = [
"libc",
"memchr",
@@ -6620,7 +6598,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest 0.13.3",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_repr",
@@ -6633,7 +6611,7 @@ dependencies = [
"tauri-utils",
"thiserror 2.0.18",
"tokio",
"tray-icon 0.23.1",
"tray-icon",
"url",
"webkit2gtk",
"webview2-com",
@@ -6974,7 +6952,7 @@ dependencies = [
"serde_with",
"swift-rs",
"thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0",
"toml 1.1.2+spec-1.1.0",
"url",
"urlpattern",
"uuid",
@@ -6999,7 +6977,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.59.0",
@@ -7507,27 +7485,6 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "tray-icon"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e47e6d063cfe4ad2e416fcbb310be3a37c5fd85c745b62cb562bfa4a003df674"
dependencies = [
"crossbeam-channel",
"dirs",
"libappindicator",
"muda",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png 0.18.1",
"thiserror 2.0.18",
"windows-sys 0.60.2",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
@@ -7585,9 +7542,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.20.0"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
@@ -7845,9 +7802,9 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.23.1"
version = "1.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -9084,9 +9041,9 @@ dependencies = [
[[package]]
name = "zbus"
version = "5.15.0"
version = "5.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1"
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
dependencies = [
"async-broadcast",
"async-executor",
@@ -9119,9 +9076,9 @@ dependencies = [
[[package]]
name = "zbus_macros"
version = "5.15.0"
version = "5.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff"
checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
@@ -9145,18 +9102,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.48"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [
"proc-macro2",
"quote",
@@ -9352,9 +9309,9 @@ dependencies = [
[[package]]
name = "zvariant"
version = "5.11.0"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee"
checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0"
dependencies = [
"endi",
"enumflags2",
@@ -9366,9 +9323,9 @@ dependencies = [
[[package]]
name = "zvariant_derive"
version = "5.11.0"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda"
checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro2",
@@ -9379,9 +9336,9 @@ dependencies = [
[[package]]
name = "zvariant_utils"
version = "3.3.1"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691"
checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6"
dependencies = [
"proc-macro2",
"quote",
+5 -11
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.24.4"
version = "0.25.0"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -24,10 +24,6 @@ path = "src/main.rs"
name = "donut-proxy"
path = "src/bin/proxy_server.rs"
[[bin]]
name = "donut-daemon"
path = "src/bin/donut_daemon.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
resvg = "0.47"
@@ -87,7 +83,7 @@ cbc = "0.2"
ring = "0.17"
sha2 = "0.11"
shadowsocks = { version = "1.24", default-features = false, features = ["aead-cipher"] }
hyper = { version = "1.8", features = ["full"] }
hyper = { version = "1.10", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
clap = { version = "4", features = ["derive"] }
@@ -98,7 +94,7 @@ playwright = { git = "https://github.com/zhom/playwright-rust", branch = "master
# Wayfern CDP integration
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
rusqlite = { version = "0.39", features = ["bundled"] }
rusqlite = { version = "0.40", features = ["bundled"] }
serde_yaml = "0.9"
toml = "1.1"
thiserror = "2.0"
@@ -111,9 +107,7 @@ quick-xml = { version = "0.40", features = ["serialize"] }
boringtun = "0.7"
smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
# Daemon dependencies (tray icon)
tray-icon = "0.24"
tao = "0.35"
# Tray icon decoding (main-process system tray)
image = "0.25"
dirs = "6"
crossbeam-channel = "0.5"
@@ -145,7 +139,7 @@ windows = { version = "0.62", features = [
[dev-dependencies]
tempfile = "3.24.0"
wiremock = "0.6"
hyper = { version = "1.8", features = ["full"] }
hyper = { version = "1.10", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
tower = "0.5"
+5 -11
View File
@@ -5,7 +5,7 @@ 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 icon PNG files from SVG (macOS template icon format)
generate_tray_icons();
#[cfg(target_os = "macos")]
@@ -93,19 +93,13 @@ fn external_binaries_exist() -> bool {
let binaries_dir = PathBuf::from(&manifest_dir).join("binaries");
// Check for all required external binaries (must match tauri.conf.json externalBin)
let (donut_proxy_name, donut_daemon_name) = if target.contains("windows") {
(
format!("donut-proxy-{}.exe", target),
format!("donut-daemon-{}.exe", target),
)
let donut_proxy_name = if target.contains("windows") {
format!("donut-proxy-{}.exe", target)
} else {
(
format!("donut-proxy-{}", target),
format!("donut-daemon-{}", target),
)
format!("donut-proxy-{}", target)
};
binaries_dir.join(&donut_proxy_name).exists() && binaries_dir.join(&donut_daemon_name).exists()
binaries_dir.join(&donut_proxy_name).exists()
}
fn ensure_dist_folder_exists() {
-1
View File
@@ -77,4 +77,3 @@ function copyBinary(baseName) {
}
copyBinary("donut-proxy");
copyBinary("donut-daemon");
-3
View File
@@ -102,6 +102,3 @@ copy_binary() {
# Copy donut-proxy binary
copy_binary "donut-proxy"
# Copy donut-daemon binary
copy_binary "donut-daemon"
+46 -22
View File
@@ -1,6 +1,5 @@
use crate::browser::ProxySettings;
use crate::camoufox_manager::CamoufoxConfig;
use crate::daemon_ws::{ws_handler, WsState};
use crate::events;
use crate::group_manager::GROUP_MANAGER;
use crate::profile::manager::ProfileManager;
@@ -412,16 +411,9 @@ impl ApiServer {
))
.layer(middleware::from_fn(terms_check_middleware));
// Create WebSocket route with its own state (no auth required for daemon IPC)
let ws_state = WsState::new();
let ws_routes = Router::new()
.route("/events", get(ws_handler))
.with_state(ws_state);
let api_for_v1 = api.clone();
let app = Router::new()
.merge(v1_routes)
.nest("/ws", ws_routes)
.route("/openapi.json", get(move || async move { Json(api) }))
.route(
"/v1/openapi.json",
@@ -594,6 +586,24 @@ pub async fn get_api_server_status() -> Result<Option<u16>, String> {
Ok(server_guard.get_port())
}
/// Serialize a browser config (camoufox/wayfern) to JSON for an API response,
/// dropping the `fingerprint` field unless the user has an active paid plan.
/// Viewing fingerprints is a paid feature, so free users (and unauthenticated
/// API/MCP callers) must never receive it. `is_paid` is resolved once per
/// handler via `has_active_paid_subscription()`.
fn config_to_api_value<T: serde::Serialize>(
config: Option<&T>,
is_paid: bool,
) -> Option<serde_json::Value> {
let mut value = serde_json::to_value(config?).ok()?;
if !is_paid {
if let Some(obj) = value.as_object_mut() {
obj.remove("fingerprint");
}
}
Some(value)
}
// API Handlers - Profiles
#[utoipa::path(
get,
@@ -610,6 +620,9 @@ pub async fn get_api_server_status() -> Result<Option<u16>, String> {
)]
async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
let is_paid = crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await;
match profile_manager.list_profiles() {
Ok(profiles) => {
let api_profiles: Vec<ApiProfile> = profiles
@@ -624,10 +637,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type.clone(),
camoufox_config: profile
.camoufox_config
.as_ref()
.and_then(|c| serde_json::to_value(c).ok()),
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
group_id: profile.group_id.clone(),
tags: profile.tags.clone(),
is_running: profile.process_id.is_some(), // Simple check based on process_id
@@ -667,6 +677,9 @@ async fn get_profile(
State(_state): State<ApiServerState>,
) -> Result<Json<ApiProfileResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
let is_paid = crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await;
match profile_manager.list_profiles() {
Ok(profiles) => {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
@@ -681,10 +694,7 @@ async fn get_profile(
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type.clone(),
camoufox_config: profile
.camoufox_config
.as_ref()
.and_then(|c| serde_json::to_value(c).ok()),
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
group_id: profile.group_id.clone(),
tags: profile.tags.clone(),
is_running: profile.process_id.is_some(), // Simple check based on process_id
@@ -720,6 +730,9 @@ async fn create_profile(
Json(request): Json<CreateProfileRequest>,
) -> Result<Json<ApiProfileResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
let is_paid = crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.await;
// Parse camoufox config if provided
let camoufox_config = if let Some(config) = &request.camoufox_config {
@@ -735,6 +748,18 @@ async fn create_profile(
None
};
// Reject a dead/unreachable proxy or VPN before creating the profile. A 402
// (expired proxy subscription) maps to 402; anything else is a 400.
if let Err(err) =
crate::validate_profile_network(request.proxy_id.as_deref(), request.vpn_id.as_deref()).await
{
return Err(if err.contains("PROXY_PAYMENT_REQUIRED") {
StatusCode::PAYMENT_REQUIRED
} else {
StatusCode::BAD_REQUEST
});
}
// Create profile using the async create_profile_with_group method
match profile_manager
.create_profile_with_group(
@@ -784,10 +809,7 @@ async fn create_profile(
process_id: profile.process_id,
last_launch: profile.last_launch,
release_type: profile.release_type,
camoufox_config: profile
.camoufox_config
.as_ref()
.and_then(|c| serde_json::to_value(c).ok()),
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
group_id: profile.group_id,
tags: profile.tags,
is_running: false,
@@ -1750,13 +1772,15 @@ async fn run_profile(
port
};
// Use the same launch method as the main app, but with remote debugging enabled
match crate::browser_runner::launch_browser_profile_with_debugging(
// Use the same launch path as the main app, but force a fresh instance with
// remote debugging enabled so the returned port is the one the browser binds.
match crate::browser_runner::launch_browser_profile_impl(
state.app_handle.clone(),
profile.clone(),
url,
Some(remote_debugging_port),
headless,
true,
)
.await
{
+28
View File
@@ -26,6 +26,23 @@ pub fn is_portable() -> bool {
portable_dir().is_some()
}
/// Optional single-root override for all on-disk state. Set
/// `DONUTBROWSER_DATA_ROOT=/path` (e.g. a tmpfs mount) to relocate
/// data/cache/logs under `<root>/{data,cache,logs}` without touching the real
/// dev/prod directories. The more specific `DONUTBROWSER_DATA_DIR` /
/// `DONUTBROWSER_CACHE_DIR` overrides still take precedence over this.
fn data_root() -> Option<PathBuf> {
std::env::var_os("DONUTBROWSER_DATA_ROOT")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}
/// Log directory when `DONUTBROWSER_DATA_ROOT` is set (`<root>/logs`); `None`
/// otherwise, in which case the platform default app log dir is used.
pub fn log_dir_override() -> Option<PathBuf> {
data_root().map(|root| root.join("logs"))
}
pub fn app_name() -> &'static str {
if cfg!(debug_assertions) {
"DonutBrowserDev"
@@ -46,6 +63,10 @@ pub fn data_dir() -> PathBuf {
return PathBuf::from(dir);
}
if let Some(root) = data_root() {
return root.join("data");
}
if let Some(dir) = portable_dir() {
return dir.join("data");
}
@@ -65,6 +86,10 @@ pub fn cache_dir() -> PathBuf {
return PathBuf::from(dir);
}
if let Some(root) = data_root() {
return root.join("cache");
}
if let Some(dir) = portable_dir() {
return dir.join("cache");
}
@@ -112,6 +137,9 @@ pub fn dns_blocklist_dir() -> PathBuf {
/// `LogDir` target used in the plugin builder so the path matches what's
/// actually on disk for this OS.
pub fn log_dir<R: tauri::Runtime>(handle: &tauri::AppHandle<R>) -> PathBuf {
if let Some(dir) = log_dir_override() {
return dir;
}
use tauri::Manager;
handle
.path()
+1
View File
@@ -703,6 +703,7 @@ mod tests {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
}
}
-498
View File
@@ -1,498 +0,0 @@
// Donut Browser Daemon - Background process for tray icon and services
// This runs independently of the main Tauri GUI
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tao::event::{Event, StartCause};
use tao::event_loop::{ControlFlow, EventLoopBuilder};
use tokio::runtime::Runtime;
use tray_icon::menu::MenuEvent;
use tray_icon::TrayIcon;
#[cfg(not(target_os = "macos"))]
use tray_icon::{MouseButton, TrayIconEvent};
use donutbrowser_lib::daemon::{autostart, services, tray};
static SHOULD_QUIT: AtomicBool = AtomicBool::new(false);
#[cfg(windows)]
fn win_process_exists(pid: u32) -> bool {
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
extern "system" {
fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut ();
fn CloseHandle(hObject: *mut ()) -> i32;
}
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
false
} else {
unsafe { CloseHandle(handle) };
true
}
}
enum ServiceStatus {
Ready {
api_port: Option<u16>,
mcp_running: bool,
},
Failed(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct DaemonState {
daemon_pid: Option<u32>,
api_port: Option<u16>,
mcp_running: bool,
version: String,
}
fn get_state_path() -> PathBuf {
autostart::get_data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("daemon-state.json")
}
fn ensure_data_dir() -> std::io::Result<()> {
if let Some(data_dir) = autostart::get_data_dir() {
fs::create_dir_all(&data_dir)?;
}
Ok(())
}
fn read_state() -> DaemonState {
let path = get_state_path();
if path.exists() {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(state) = serde_json::from_str(&content) {
return state;
}
}
}
DaemonState::default()
}
fn write_state(state: &DaemonState) -> std::io::Result<()> {
let path = get_state_path();
let content = serde_json::to_string_pretty(state)?;
fs::write(path, content)
}
fn set_high_priority() {
#[cfg(unix)]
{
// Set high priority so the daemon is killed last under resource pressure
// Negative nice value = higher priority. Try -10, fall back to -5 if it fails.
unsafe {
if libc::setpriority(libc::PRIO_PROCESS, 0, -10) != 0 {
let _ = libc::setpriority(libc::PRIO_PROCESS, 0, -5);
}
}
}
#[cfg(windows)]
{
use windows::Win32::Foundation::CloseHandle;
use windows::Win32::System::Threading::{
GetCurrentProcess, SetPriorityClass, ABOVE_NORMAL_PRIORITY_CLASS,
};
// Set high priority so the daemon is killed last under resource pressure
unsafe {
let handle = GetCurrentProcess();
let _ = SetPriorityClass(handle, ABOVE_NORMAL_PRIORITY_CLASS);
// GetCurrentProcess returns a pseudo-handle that doesn't need to be closed,
// but we do it anyway for consistency
let _ = CloseHandle(handle);
}
}
}
fn run_daemon() {
// Set high priority so the daemon is less likely to be killed under resource pressure
set_high_priority();
// Initialize logging to file for debugging (since stdout/stderr may be redirected)
let log_path = autostart::get_data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("daemon.log");
let log_file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path);
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.format_timestamp_millis()
.target(if let Ok(file) = log_file {
env_logger::Target::Pipe(Box::new(file))
} else {
env_logger::Target::Stderr
})
.init();
if let Err(e) = ensure_data_dir() {
eprintln!("Failed to create data directory: {}", e);
process::exit(1);
}
log::info!("[daemon] Starting with PID {}", process::id());
// Create tokio runtime for async operations
let rt = Runtime::new().expect("Failed to create tokio runtime");
// Create channel for service status updates
let (tx, rx) = mpsc::channel::<ServiceStatus>();
// Spawn services in a background thread so we don't block the event loop
let rt_handle = rt.handle().clone();
std::thread::spawn(move || {
let result = rt_handle.block_on(async { services::DaemonServices::start().await });
let status = match result {
Ok(s) => ServiceStatus::Ready {
api_port: s.api_port,
mcp_running: s.mcp_running,
},
Err(e) => ServiceStatus::Failed(e),
};
let _ = tx.send(status);
});
// Write initial state (services still starting)
let state = DaemonState {
daemon_pid: Some(process::id()),
api_port: None,
mcp_running: false,
version: env!("CARGO_PKG_VERSION").to_string(),
};
if let Err(e) = write_state(&state) {
log::error!("Failed to write state: {}", e);
}
// Prepare tray menu and icon (but don't create the tray icon yet)
let tray_menu = tray::TrayMenu::new();
let icon = tray::load_icon();
let menu_channel = MenuEvent::receiver();
// Create the event loop IMMEDIATELY (critical for macOS tray icon)
let event_loop = EventLoopBuilder::new().build();
// Store tray icon in Option - created after event loop starts
let mut tray_icon: Option<TrayIcon> = None;
// Install signal handlers so SIGTERM/SIGINT trigger graceful shutdown
#[cfg(unix)]
unsafe {
extern "C" fn signal_handler(_sig: libc::c_int) {
SHOULD_QUIT.store(true, std::sync::atomic::Ordering::SeqCst);
}
libc::signal(
libc::SIGTERM,
signal_handler as *const () as libc::sighandler_t,
);
libc::signal(
libc::SIGINT,
signal_handler as *const () as libc::sighandler_t,
);
}
#[cfg(windows)]
{
extern "system" {
fn SetConsoleCtrlHandler(
handler: Option<unsafe extern "system" fn(u32) -> i32>,
add: i32,
) -> i32;
}
unsafe extern "system" fn ctrl_handler(_ctrl_type: u32) -> i32 {
SHOULD_QUIT.store(true, std::sync::atomic::Ordering::SeqCst);
1 // TRUE
}
unsafe {
SetConsoleCtrlHandler(Some(ctrl_handler), 1);
}
}
// Run the event loop
event_loop.run(move |event, _, control_flow| {
// 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) => {
// Hide from dock on macOS (must be done after event loop starts)
#[cfg(target_os = "macos")]
{
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
}
}
// Create tray icon after event loop has started (required for macOS)
tray_icon = Some(tray::create_tray_icon(icon.clone(), &tray_menu.menu));
log::info!("[daemon] Tray icon created");
}
Event::MainEventsCleared => {
// Check for service status updates from background thread
if let Ok(status) = rx.try_recv() {
match status {
ServiceStatus::Ready {
api_port,
mcp_running,
} => {
log::info!("[daemon] Services started successfully");
// Update state file
let mut state = read_state();
state.api_port = api_port;
state.mcp_running = mcp_running;
if let Err(e) = write_state(&state) {
log::error!("Failed to write state: {}", e);
}
}
ServiceStatus::Failed(e) => {
log::error!("Failed to start services: {}", e);
}
}
}
// Process menu events
while let Ok(event) = menu_channel.try_recv() {
if event.id == tray_menu.quit_item.id() {
log::info!("[daemon] Quit requested");
SHOULD_QUIT.store(true, Ordering::SeqCst);
}
}
// Handle tray icon click (left-click opens the app)
// On macOS, left-click already shows the menu, so don't also launch the GUI.
#[cfg(not(target_os = "macos"))]
while let Ok(event) = TrayIconEvent::receiver().try_recv() {
if let TrayIconEvent::Click {
button: MouseButton::Left,
..
} = event
{
tray::open_gui();
}
}
// Use swap to only run cleanup once
if SHOULD_QUIT.swap(false, Ordering::SeqCst) {
// Remove tray icon from status bar immediately so the UI feels responsive
tray_icon = None;
tray::quit_gui();
let mut state = read_state();
state.daemon_pid = None;
let _ = write_state(&state);
log::info!("[daemon] Exiting");
// Use process::exit for immediate termination instead of ControlFlow::Exit.
// ControlFlow::Exit can delay because tao's macOS event loop defers exit,
// and dropping the tokio runtime blocks until all spawned tasks finish.
process::exit(0);
}
}
Event::Reopen { .. } => {
tray::open_gui();
// Re-hide daemon from Dock. macOS activates the daemon (making it
// visible) when the user clicks the Dock icon, overriding the
// Accessory policy set at init.
#[cfg(target_os = "macos")]
{
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
}
}
}
_ => {}
}
// Keep tray_icon alive
let _ = &tray_icon;
// Keep runtime alive
let _ = &rt;
});
}
fn stop_daemon() {
let state = read_state();
if let Some(pid) = state.daemon_pid {
// On Windows, taskkill /F kills instantly with no handler, so kill GUI first
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let state_path = get_state_path();
if let Ok(content) = fs::read_to_string(&state_path) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(gui_pid) = val.get("gui_pid").and_then(|v| v.as_u64()) {
let _ = Command::new("taskkill")
.args(["/PID", &gui_pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
}
}
let _ = Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output();
eprintln!("Sent stop signal to daemon (PID {})", pid);
}
#[cfg(unix)]
{
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
eprintln!("Sent stop signal to daemon (PID {})", pid);
}
} else {
eprintln!("Daemon is not running");
}
}
fn show_status() {
let state = read_state();
if let Some(pid) = state.daemon_pid {
#[cfg(unix)]
let is_running = unsafe { libc::kill(pid as i32, 0) == 0 };
#[cfg(windows)]
let is_running = win_process_exists(pid);
#[cfg(not(any(unix, windows)))]
let is_running = false;
if is_running {
eprintln!("Daemon is running (PID {})", pid);
if let Some(port) = state.api_port {
eprintln!(" API: Running on port {}", port);
} else {
eprintln!(" API: Stopped");
}
eprintln!(
" MCP: {}",
if state.mcp_running {
"Running"
} else {
"Stopped"
}
);
} else {
eprintln!("Daemon is not running (stale PID in state file)");
}
} else {
eprintln!("Daemon is not running");
}
}
fn print_usage() {
eprintln!("Donut Browser Daemon");
eprintln!();
eprintln!("Usage: donut-daemon <command>");
eprintln!();
eprintln!("Commands:");
eprintln!(" start Start the daemon (detaches from terminal)");
eprintln!(" stop Stop the running daemon");
eprintln!(" status Show daemon status");
eprintln!(" run Run in foreground (for debugging)");
eprintln!(" autostart Manage autostart settings");
eprintln!(" enable Enable autostart on login");
eprintln!(" disable Disable autostart on login");
eprintln!(" status Show autostart status");
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
print_usage();
process::exit(1);
}
match args[1].as_str() {
"start" => {
run_daemon();
}
"stop" => {
stop_daemon();
}
"status" => {
show_status();
}
"run" => {
run_daemon();
}
"autostart" => {
if args.len() < 3 {
eprintln!("Usage: donut-daemon autostart <enable|disable|status>");
process::exit(1);
}
match args[2].as_str() {
"enable" => {
if let Err(e) = autostart::enable_autostart() {
eprintln!("Failed to enable autostart: {}", e);
process::exit(1);
}
eprintln!("Autostart enabled");
}
"disable" => {
if let Err(e) = autostart::disable_autostart() {
eprintln!("Failed to disable autostart: {}", e);
process::exit(1);
}
eprintln!("Autostart disabled");
}
"status" => {
if autostart::is_autostart_enabled() {
eprintln!("Autostart is enabled");
} else {
eprintln!("Autostart is disabled");
}
}
_ => {
eprintln!("Unknown autostart command: {}", args[2]);
process::exit(1);
}
}
}
_ => {
print_usage();
process::exit(1);
}
}
}
+1
View File
@@ -1220,6 +1220,7 @@ mod tests {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
let path = profile.get_profile_data_path(&profiles_dir);
+68 -276
View File
@@ -7,78 +7,11 @@ use crate::platform_browser;
use crate::profile::{BrowserProfile, ProfileManager};
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::{WayfernConfig, WayfernManager};
use chrono::{Datelike, TimeZone, Utc};
use serde::Serialize;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sysinfo::System;
/// Fixed UTC hour at which Wayfern fingerprints rotate. Picked to land in a
/// low-traffic window for the average user; everyone shares the same UTC
/// instant so the value here doesn't track any one user's local schedule.
const FINGERPRINT_ROLLOVER_HOUR_UTC: u32 = 4;
/// File name of the per-profile marker recording the last fingerprint
/// refresh time. Lives at `<profiles_dir>/<profile_id>/.last-fp-refresh`
/// and is excluded from cloud sync (see `sync::manifest`) so each device
/// runs its own refresh schedule.
const LAST_FP_REFRESH_FILE: &str = ".last-fp-refresh";
/// Most recent rollover instant on or before `now` — used as a staleness
/// threshold for Wayfern fingerprints. Anything generated before this
/// timestamp is considered stale and gets regenerated on next launch.
fn most_recent_rollover_epoch() -> u64 {
let now = Utc::now();
let today_threshold = Utc
.with_ymd_and_hms(
now.year(),
now.month(),
now.day(),
FINGERPRINT_ROLLOVER_HOUR_UTC,
0,
0,
)
.single()
.unwrap_or(now);
let threshold = if now >= today_threshold {
today_threshold
} else {
today_threshold - chrono::Duration::days(1)
};
threshold.timestamp().max(0) as u64
}
fn last_fp_refresh_path(profile_id: &str, profiles_dir: &std::path::Path) -> PathBuf {
profiles_dir.join(profile_id).join(LAST_FP_REFRESH_FILE)
}
/// Read the epoch-seconds timestamp stored in the per-profile refresh marker.
/// Returns `None` if the file doesn't exist or its content can't be parsed —
/// both signal "needs a refresh" to the caller.
fn read_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path) -> Option<u64> {
let path = last_fp_refresh_path(profile_id, profiles_dir);
let content = std::fs::read_to_string(&path).ok()?;
content.trim().parse::<u64>().ok()
}
/// Record `ts` (epoch seconds) as the most recent fingerprint refresh for
/// this profile. Failure is logged but never propagated — a missing marker
/// only costs an extra regen on the next launch, never blocks one.
fn write_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path, ts: u64) {
let path = last_fp_refresh_path(profile_id, profiles_dir);
if let Some(parent) = path.parent() {
if !parent.exists() {
if let Err(e) = std::fs::create_dir_all(parent) {
log::warn!("Failed to create profile dir for fingerprint refresh marker {profile_id}: {e}");
return;
}
}
}
if let Err(e) = std::fs::write(&path, ts.to_string()) {
log::warn!("Failed to write fingerprint refresh marker for {profile_id}: {e}");
}
}
pub struct BrowserRunner {
pub profile_manager: &'static ProfileManager,
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
@@ -448,6 +381,7 @@ impl BrowserRunner {
camoufox_config,
url,
override_profile_path,
remote_debugging_port,
headless,
)
.await
@@ -612,32 +546,12 @@ impl BrowserRunner {
wayfern_config.proxy
);
// Decide whether to (re)generate the Wayfern fingerprint for this
// launch. Two triggers:
//
// 1. `randomize_fingerprint_on_launch = true` — explicit per-launch
// randomization the user opted into.
// 2. The fingerprint hasn't been refreshed since the most recent
// rollover instant. We check the per-profile marker file first
// (`.last-fp-refresh`); if it's absent we fall back to
// `profile.created_at` so brand-new profiles don't immediately
// regenerate the fingerprint they were just created with.
// Profiles with neither (truly legacy) are treated as ancient
// and refresh on next launch — once.
// Check if we need to generate a new fingerprint on every launch
let mut updated_profile = profile.clone();
let stale_threshold = most_recent_rollover_epoch();
let profile_id_str = profile.id.to_string();
let profiles_dir_for_marker = self.profile_manager.get_profiles_dir();
let effective_last_refresh =
read_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker).or(profile.created_at);
let is_stale_profile = effective_last_refresh.is_none_or(|ts| ts < stale_threshold);
let randomize_every_launch = wayfern_config.randomize_fingerprint_on_launch == Some(true);
if randomize_every_launch || is_stale_profile {
if wayfern_config.randomize_fingerprint_on_launch == Some(true) {
log::info!(
"Generating Wayfern fingerprint for profile {} (per-launch={}, rollover={})",
profile.name,
randomize_every_launch,
is_stale_profile
"Generating random fingerprint for Wayfern profile: {}",
profile.name
);
// Create a config copy without the existing fingerprint to force generation of a new one
@@ -659,24 +573,12 @@ impl BrowserRunner {
// Update the config with the new fingerprint for launching
wayfern_config.fingerprint = Some(new_fingerprint.clone());
// Write the marker so the next launch within the same rollover
// window skips this branch. The marker is excluded from cloud
// sync (see `sync::manifest::DEFAULT_EXCLUDE_PATTERNS`), so each
// device's refresh schedule is independent.
let now_epoch = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(stale_threshold);
write_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker, now_epoch);
// Save the updated fingerprint to the profile so it persists.
let mut updated_wayfern_config = updated_profile.wayfern_config.clone().unwrap_or_default();
updated_wayfern_config.fingerprint = Some(new_fingerprint);
// Preserve the user's randomize-on-launch preference rather than
// forcing it on. The rollover path must not silently flip this
// flag for users who only opted into the scheduled refresh.
updated_wayfern_config.randomize_fingerprint_on_launch =
wayfern_config.randomize_fingerprint_on_launch;
// Preserve the randomize flag so it persists across launches
updated_wayfern_config.randomize_fingerprint_on_launch = Some(true);
// Preserve the OS setting so it's used for future fingerprint generation
if wayfern_config.os.is_some() {
updated_wayfern_config.os = wayfern_config.os.clone();
}
@@ -754,6 +656,24 @@ impl BrowserRunner {
let process_id = wayfern_result.processId.unwrap_or(0);
log::info!("Wayfern launched successfully with PID: {process_id}");
// Wayfern.setFingerprint echoes back the fingerprint the browser actually
// applied, which may be UPGRADED from the stored one (e.g. when the
// stored fingerprint targets an older browser version). Persist it so the
// next launch starts from the upgraded value — saved below via
// save_process_info(&updated_profile).
if let Some(used_fp) = wayfern_result.used_fingerprint.clone() {
let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default();
if cfg.fingerprint.as_deref() != Some(used_fp.as_str()) {
log::info!(
"Persisting upgraded fingerprint from Wayfern.setFingerprint for profile: {} (len {})",
profile.name,
used_fp.len()
);
cfg.fingerprint = Some(used_fp);
updated_profile.wayfern_config = Some(cfg);
}
}
// Update profile with the process info
updated_profile.process_id = Some(process_id);
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
@@ -935,57 +855,19 @@ impl BrowserRunner {
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
// Always start a local proxy for API launches
let upstream_proxy = self
.resolve_launch_proxy(profile)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
let temp_pid = 1u32;
let profile_id_str = profile.id.to_string();
// Start local proxy - if this fails, DO NOT launch browser
let blocklist_file = Self::resolve_blocklist_file(profile)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
let internal_proxy = PROXY_MANAGER
.start_proxy(
app_handle.clone(),
upstream_proxy.as_ref(),
temp_pid,
Some(&profile_id_str),
profile.proxy_bypass_rules.clone(),
blocklist_file,
)
.await
.map_err(|e| {
let error_msg = format!("Failed to start local proxy: {e}");
log::error!("{}", error_msg);
error_msg
})?;
let internal_proxy_settings = Some(internal_proxy.clone());
let result = self
// Camoufox and Wayfern start (and PID-reconcile) their own local proxy
// inside `launch_browser_internal`, so we hand it None here rather than
// staging a second, orphaned proxy worker.
self
.launch_browser_internal(
app_handle.clone(),
app_handle,
profile,
url,
internal_proxy_settings.as_ref(),
None,
remote_debugging_port,
headless,
)
.await;
// Update proxy with correct PID if launch succeeded
if let Ok(ref updated_profile) = result {
if let Some(actual_pid) = updated_profile.process_id {
let _ = PROXY_MANAGER.update_proxy_pid(temp_pid, actual_pid);
}
}
result
.await
}
pub async fn launch_or_open_url(
@@ -2395,6 +2277,17 @@ pub async fn launch_browser_profile(
app_handle: tauri::AppHandle,
profile: BrowserProfile,
url: Option<String>,
) -> Result<BrowserProfile, String> {
launch_browser_profile_impl(app_handle, profile, url, None, false, false).await
}
pub async fn launch_browser_profile_impl(
app_handle: tauri::AppHandle,
profile: BrowserProfile,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
force_new: bool,
) -> Result<BrowserProfile, String> {
log::info!(
"Launch request received for profile: {} (ID: {})",
@@ -2424,9 +2317,6 @@ pub async fn launch_browser_profile(
let browser_runner = BrowserRunner::instance();
// Store the internal proxy settings for passing to launch_browser
let mut internal_proxy_settings: Option<ProxySettings> = None;
// Resolve the most up-to-date profile from disk by ID to avoid using stale proxy_id/browser state
let profile_for_launch = match browser_runner
.profile_manager
@@ -2448,112 +2338,36 @@ pub async fn launch_browser_profile(
profile_for_launch.id
);
// Always start a local proxy before launching (non-Camoufox/Wayfern handled here; they have their own flow)
// This ensures all traffic goes through the local proxy for monitoring and future features
if profile.browser != "camoufox" && profile.browser != "wayfern" {
// Determine upstream proxy if configured; otherwise use DIRECT (no upstream)
// Refresh cloud proxy credentials and inject profile-specific sid
let mut upstream_proxy = BrowserRunner::instance()
.resolve_launch_proxy(&profile_for_launch)
.await?;
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
if let Some(ref vpn_id) = profile_for_launch.vpn_id {
match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await {
Ok(vpn_worker) => {
if let Some(port) = vpn_worker.local_port {
upstream_proxy = Some(ProxySettings {
proxy_type: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port,
username: None,
password: None,
});
log::info!("VPN worker started for profile on port {}", port);
}
}
Err(e) => {
return Err(format!("Failed to start VPN worker: {e}"));
}
}
}
}
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
let temp_pid = 1u32;
let profile_id_str = profile.id.to_string();
// Always start a local proxy, even if there's no upstream proxy
// This allows for traffic monitoring and future features
let blocklist_file = BrowserRunner::resolve_blocklist_file(&profile_for_launch).await?;
match PROXY_MANAGER
.start_proxy(
app_handle.clone(),
upstream_proxy.as_ref(),
temp_pid,
Some(&profile_id_str),
profile_for_launch.proxy_bypass_rules.clone(),
blocklist_file,
)
.await
{
Ok(internal_proxy) => {
// Use internal proxy for subsequent launch
internal_proxy_settings = Some(internal_proxy.clone());
// For Firefox-based browsers, always apply PAC/user.js to point to the local proxy
if matches!(
profile_for_launch.browser.as_str(),
"firefox" | "firefox-developer" | "zen"
) {
let profiles_dir = browser_runner.profile_manager.get_profiles_dir();
let profile_path = profiles_dir
.join(profile_for_launch.id.to_string())
.join("profile");
// Provide a dummy upstream (ignored when internal proxy is provided)
let dummy_upstream = ProxySettings {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: internal_proxy.port,
username: None,
password: None,
};
browser_runner
.profile_manager
.apply_proxy_settings_to_profile(&profile_path, &dummy_upstream, Some(&internal_proxy))
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
}
log::info!(
"Local proxy prepared for profile: {} on port: {} (upstream: {})",
profile_for_launch.name,
internal_proxy.port,
upstream_proxy
.as_ref()
.map(|p| format!("{}:{}", p.host, p.port))
.unwrap_or_else(|| "DIRECT".to_string())
);
}
Err(e) => {
let error_msg = format!("Failed to start local proxy: {e}");
log::error!("{}", error_msg);
// DO NOT launch browser if proxy startup fails - all browsers must use local proxy
return Err(error_msg);
}
}
}
log::info!(
"Starting browser launch for profile: {} (ID: {})",
profile_for_launch.name,
profile_for_launch.id
);
// Launch browser or open URL in existing instance
let updated_profile = browser_runner.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, internal_proxy_settings.as_ref()).await.map_err(|e| {
// Launch browser or open URL in existing instance. Camoufox and Wayfern
// start their own local proxies inside `launch_browser_internal`; any
// other browser type is rejected there (we only support those for import,
// not launch), so no proxy needs to be staged here.
//
// `force_new` callers (API/MCP) always start a fresh instance with the
// requested debug port and headless mode, bypassing the "open URL in the
// existing window" path which would otherwise ignore both.
let launch_result = if force_new {
browser_runner
.launch_browser_with_debugging(
app_handle.clone(),
&profile_for_launch,
url,
remote_debugging_port,
headless,
)
.await
} else {
browser_runner
.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, None)
.await
};
let updated_profile = launch_result.map_err(|e| {
log::info!("Browser launch failed for profile: {}, error: {}", profile_for_launch.name, e);
// Emit a failure event to clear loading states in the frontend
@@ -2710,28 +2524,6 @@ pub async fn kill_browser_profile(
}
}
pub async fn launch_browser_profile_with_debugging(
app_handle: tauri::AppHandle,
profile: BrowserProfile,
url: Option<String>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<BrowserProfile, String> {
if profile.is_cross_os() {
return Err(format!(
"Cannot launch profile '{}': this profile was created on {} and cannot be launched on a different operating system",
profile.name,
profile.host_os.as_deref().unwrap_or("another OS"),
));
}
let browser_runner = BrowserRunner::instance();
browser_runner
.launch_browser_with_debugging(app_handle, &profile, url, remote_debugging_port, headless)
.await
.map_err(|e| format!("Failed to launch browser with debugging: {e}"))
}
#[tauri::command]
pub async fn open_url_with_profile(
app_handle: tauri::AppHandle,
+9 -1
View File
@@ -200,6 +200,7 @@ impl CamoufoxManager {
}
/// Launch Camoufox browser by directly spawning the process
#[allow(clippy::too_many_arguments)]
pub async fn launch_camoufox(
&self,
_app_handle: &AppHandle,
@@ -207,6 +208,7 @@ impl CamoufoxManager {
profile_path: &str,
config: &CamoufoxConfig,
url: Option<&str>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<CamoufoxLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
let custom_config = if let Some(existing_fingerprint) = &config.fingerprint {
@@ -249,7 +251,10 @@ impl CamoufoxManager {
.to_string(),
];
let cdp_port = Self::find_free_port().await?;
let cdp_port = match remote_debugging_port {
Some(p) => p,
None => Self::find_free_port().await?,
};
args.push(format!("--remote-debugging-port={cdp_port}"));
// Add URL if provided
@@ -666,6 +671,7 @@ impl CamoufoxManager {
}
impl CamoufoxManager {
#[allow(clippy::too_many_arguments)]
pub async fn launch_camoufox_profile(
&self,
app_handle: AppHandle,
@@ -673,6 +679,7 @@ impl CamoufoxManager {
config: CamoufoxConfig,
url: Option<String>,
override_profile_path: Option<std::path::PathBuf>,
remote_debugging_port: Option<u16>,
headless: bool,
) -> Result<CamoufoxLaunchResult, String> {
// Get profile path
@@ -817,6 +824,7 @@ impl CamoufoxManager {
&profile_path_str,
&config,
url.as_deref(),
remote_debugging_port,
headless,
)
.await
+22 -1
View File
@@ -46,6 +46,16 @@ pub struct CloudUser {
pub team_name: Option<String>,
#[serde(rename = "teamRole", default)]
pub team_role: Option<String>,
// This desktop session's position among the user's active devices, oldest
// first. Ordinal 1 is the primary device — the only one that can run browser
// automation. `default` keeps older login/state payloads (which lack these
// fields) deserializing cleanly.
#[serde(rename = "deviceOrdinal", default)]
pub device_ordinal: Option<i64>,
#[serde(rename = "deviceCount", default)]
pub device_count: Option<i64>,
#[serde(rename = "isPrimaryDevice", default)]
pub is_primary_device: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -413,7 +423,18 @@ impl CloudAuthManager {
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Login failed ({status}): {body}"));
// The backend returns { message, code, … } for 4xx (e.g. the 3-device
// limit or a temporary security block). Surface the human-readable
// message rather than the raw JSON so the sign-in screen is clear.
let message = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| {
v.get("message")
.and_then(|m| m.as_str())
.map(std::string::ToString::to_string)
})
.unwrap_or_else(|| format!("Login failed ({status})"));
return Err(message);
}
let result: DeviceCodeExchangeResponse = response
-351
View File
@@ -1,351 +0,0 @@
use directories::ProjectDirs;
#[cfg(any(target_os = "macos", target_os = "linux"))]
use std::fs;
use std::io;
use std::path::PathBuf;
fn get_daemon_path() -> Option<PathBuf> {
// First try to find the daemon binary in the same directory as the current executable
if let Ok(current_exe) = std::env::current_exe() {
let daemon_path = current_exe.parent()?.join(daemon_binary_name());
if daemon_path.exists() {
return Some(daemon_path);
}
}
// Try common installation paths
#[cfg(target_os = "macos")]
{
let paths = [
PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
dirs::home_dir()?.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
#[cfg(target_os = "windows")]
{
let paths = [
dirs::data_local_dir()?.join("Donut Browser/donut-daemon.exe"),
PathBuf::from("C:\\Program Files\\Donut Browser\\donut-daemon.exe"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
#[cfg(target_os = "linux")]
{
let paths = [
PathBuf::from("/usr/bin/donut-daemon"),
PathBuf::from("/usr/local/bin/donut-daemon"),
dirs::home_dir()?.join(".local/bin/donut-daemon"),
];
for path in paths {
if path.exists() {
return Some(path);
}
}
}
None
}
fn daemon_binary_name() -> &'static str {
#[cfg(windows)]
{
"donut-daemon.exe"
}
#[cfg(not(windows))]
{
"donut-daemon"
}
}
#[cfg(target_os = "macos")]
pub fn enable_autostart() -> io::Result<()> {
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let plist_dir = dirs::home_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?
.join("Library/LaunchAgents");
fs::create_dir_all(&plist_dir)?;
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">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.donutbrowser.daemon</string>
<key>ProgramArguments</key>
<array>
<string>{daemon_path}</string>
<string>run</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>ProcessType</key>
<string>Interactive</string>
<key>StandardOutPath</key>
<string>{log_dir}/daemon.out.log</string>
<key>StandardErrorPath</key>
<string>{log_dir}/daemon.err.log</string>
</dict>
</plist>
"#,
daemon_path = daemon_path.display(),
log_dir = log_dir.display()
);
fs::write(&plist_path, plist_content)?;
log::info!("Created launch agent at {:?}", plist_path);
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 = 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);
}
Ok(())
}
#[cfg(target_os = "macos")]
pub fn is_autostart_enabled() -> bool {
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 start_launch_agent() -> io::Result<()> {
use std::process::Command;
let output = Command::new("launchctl")
.args(["start", "com.donutbrowser.daemon"])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(io::Error::other(format!(
"launchctl start failed: {}",
stderr
)));
}
log::info!("Started 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")]
pub fn enable_autostart() -> io::Result<()> {
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let autostart_dir = dirs::config_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))?
.join("autostart");
fs::create_dir_all(&autostart_dir)?;
let desktop_path = autostart_dir.join("donut-daemon.desktop");
let escaped_daemon_path = daemon_path
.display()
.to_string()
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('`', "\\`")
.replace('$', "\\$");
let desktop_content = format!(
r#"[Desktop Entry]
Type=Application
Name=Donut Browser Daemon
Exec="{escaped_daemon_path}" run
Hidden=false
NoDisplay=true
X-GNOME-Autostart-enabled=true
"#,
);
fs::write(&desktop_path, desktop_content)?;
log::info!("Created autostart entry at {:?}", desktop_path);
Ok(())
}
#[cfg(target_os = "linux")]
pub fn disable_autostart() -> io::Result<()> {
let desktop_path = dirs::config_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))?
.join("autostart/donut-daemon.desktop");
if desktop_path.exists() {
fs::remove_file(&desktop_path)?;
log::info!("Removed autostart entry at {:?}", desktop_path);
}
Ok(())
}
#[cfg(target_os = "linux")]
pub fn is_autostart_enabled() -> bool {
dirs::config_dir()
.map(|c| c.join("autostart/donut-daemon.desktop").exists())
.unwrap_or(false)
}
#[cfg(target_os = "windows")]
pub fn enable_autostart() -> io::Result<()> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let daemon_path = get_daemon_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let (key, _) = hkcu.create_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run")?;
key.set_value(
"DonutBrowserDaemon",
&format!("\"{}\" run", daemon_path.display()),
)?;
log::info!("Added registry autostart entry");
Ok(())
}
#[cfg(target_os = "windows")]
pub fn disable_autostart() -> io::Result<()> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(key) = hkcu.open_subkey_with_flags(
"Software\\Microsoft\\Windows\\CurrentVersion\\Run",
winreg::enums::KEY_WRITE,
) {
let _ = key.delete_value("DonutBrowserDaemon");
log::info!("Removed registry autostart entry");
}
Ok(())
}
#[cfg(target_os = "windows")]
pub fn is_autostart_enabled() -> bool {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(key) = hkcu.open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run") {
key.get_value::<String, _>("DonutBrowserDaemon").is_ok()
} else {
false
}
}
pub fn get_data_dir() -> Option<PathBuf> {
if crate::app_dirs::is_portable() {
return Some(crate::app_dirs::data_dir());
}
if let Some(proj_dirs) = ProjectDirs::from("com", "donutbrowser", "Donut Browser") {
Some(proj_dirs.data_dir().to_path_buf())
} else {
dirs::home_dir().map(|h| h.join(".donutbrowser"))
}
}
-3
View File
@@ -1,3 +0,0 @@
pub mod autostart;
pub mod services;
pub mod tray;
-51
View File
@@ -1,51 +0,0 @@
use crate::events::{self, DaemonEmitter, DaemonEvent};
use std::sync::Arc;
use tokio::sync::broadcast;
pub struct DaemonServices {
pub api_port: Option<u16>,
pub mcp_running: bool,
event_emitter: Arc<DaemonEmitter>,
}
impl DaemonServices {
pub async fn start() -> Result<Self, String> {
log::info!("Starting daemon services...");
// Create the daemon event emitter
let (emitter, _rx) = DaemonEmitter::with_capacity(256);
let emitter_arc = Arc::new(emitter);
// Set the global event emitter
if let Err(e) = events::set_global_emitter(emitter_arc.clone()) {
log::warn!("Failed to set global event emitter: {}", e);
}
// NOTE: The API server currently requires an AppHandle which is only available
// in the Tauri GUI context. For now, the daemon starts with minimal services.
// The GUI will start the API server when it connects to the daemon.
//
// TODO: Refactor API server to work without AppHandle for daemon mode
let api_port = None;
let mcp_running = false;
log::info!("Daemon services started (minimal mode - waiting for GUI connection)");
Ok(Self {
api_port,
mcp_running,
event_emitter: emitter_arc,
})
}
pub fn subscribe_events(&self) -> broadcast::Receiver<DaemonEvent> {
self.event_emitter.subscribe()
}
pub async fn stop(&mut self) {
log::info!("Stopping daemon services...");
self.api_port = None;
self.mcp_running = false;
}
}
-204
View File
@@ -1,204 +0,0 @@
use std::process::Command;
use tray_icon::menu::{Menu, MenuItem};
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
pub fn load_icon() -> Icon {
// On Windows, use the full-color icon so it renders well on dark taskbars.
// On macOS/Linux, use the template icon (black with alpha) for system light/dark handling.
#[cfg(target_os = "windows")]
let icon_bytes = include_bytes!("../../icons/tray-icon-win-44.png");
#[cfg(not(target_os = "windows"))]
let icon_bytes = include_bytes!("../../icons/tray-icon-44.png");
let image = image::load_from_memory(icon_bytes)
.expect("Failed to load icon")
.into_rgba8();
let (width, height) = image.dimensions();
let rgba = image.into_raw();
Icon::from_rgba(rgba, width, height).expect("Failed to create icon")
}
pub struct TrayMenu {
pub menu: Menu,
pub quit_item: MenuItem,
}
impl Default for TrayMenu {
fn default() -> Self {
Self::new()
}
}
impl TrayMenu {
pub fn new() -> Self {
let menu = Menu::new();
let quit_item = MenuItem::new("Quit Donut Browser", true, None);
menu.append(&quit_item).unwrap();
Self { menu, quit_item }
}
}
pub fn create_tray_icon(icon: Icon, menu: &Menu) -> TrayIcon {
let builder = TrayIconBuilder::new()
.with_icon(icon)
.with_tooltip("Donut Browser")
.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")
}
/// Resolve the .app bundle path from the current daemon executable.
/// In production the daemon is at `Donut.app/Contents/MacOS/donut-daemon`.
#[cfg(target_os = "macos")]
fn get_app_bundle_path() -> Option<std::path::PathBuf> {
let exe = std::env::current_exe().ok()?;
let macos_dir = exe.parent()?;
let contents_dir = macos_dir.parent()?;
let app_dir = contents_dir.parent()?;
if app_dir.extension().and_then(|e| e.to_str()) == Some("app") {
Some(app_dir.to_path_buf())
} else {
None
}
}
pub fn open_gui() {
log::info!("Opening GUI...");
#[cfg(target_os = "macos")]
{
// Launch the GUI binary directly. The daemon lives inside the same .app
// bundle, so `open` (even with `-n`) can re-activate the daemon instead
// of launching the GUI. Directly running the binary avoids macOS's app
// activation machinery. The single-instance Tauri plugin in the GUI
// handles deduplication if a GUI instance is already running.
if let Some(app_bundle) = get_app_bundle_path() {
let gui_binary = app_bundle.join("Contents").join("MacOS").join("Donut");
if gui_binary.exists() {
let _ = Command::new(&gui_binary).spawn();
} else {
let _ = Command::new("open").args(["-n"]).arg(&app_bundle).spawn();
}
} else {
let _ = Command::new("open").args(["-n", "-a", "Donut"]).spawn();
}
}
#[cfg(target_os = "windows")]
{
use std::path::PathBuf;
if let Ok(current_exe) = std::env::current_exe() {
if let Some(exe_dir) = current_exe.parent() {
let app_path = exe_dir.join("donutbrowser.exe");
if app_path.exists() {
let _ = Command::new(app_path).spawn();
return;
}
}
}
let paths = [
dirs::data_local_dir().map(|p| p.join("Donut Browser").join("Donut Browser.exe")),
Some(PathBuf::from(
"C:\\Program Files\\Donut Browser\\Donut Browser.exe",
)),
];
for path in paths.iter().flatten() {
if path.exists() {
let _ = Command::new(path).spawn();
return;
}
}
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("donutbrowser").spawn();
}
}
fn read_gui_pid() -> Option<u32> {
let path = super::autostart::get_data_dir()?.join("daemon-state.json");
let content = std::fs::read_to_string(path).ok()?;
let val: serde_json::Value = serde_json::from_str(&content).ok()?;
val.get("gui_pid")?.as_u64().map(|p| p as u32)
}
fn kill_gui_by_pid() -> bool {
let Some(pid) = read_gui_pid() else {
return false;
};
#[cfg(unix)]
{
let ret = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
ret == 0
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(not(any(unix, windows)))]
{
false
}
}
pub fn quit_gui() {
log::info!("[daemon] Quitting GUI...");
if kill_gui_by_pid() {
log::info!("[daemon] GUI killed by PID");
return;
}
log::info!("[daemon] PID-based kill failed, falling back to name-based kill");
#[cfg(target_os = "macos")]
{
// Use spawn() instead of output() to avoid blocking the event loop.
// AppleScript has a ~2 minute default timeout that would freeze the tray icon.
let _ = Command::new("osascript")
.args(["-e", "tell application \"Donut\" to quit"])
.spawn();
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = Command::new("taskkill")
.args(["/IM", "Donut.exe", "/F"])
.creation_flags(CREATE_NO_WINDOW)
.spawn();
let _ = Command::new("taskkill")
.args(["/IM", "donutbrowser.exe", "/F"])
.creation_flags(CREATE_NO_WINDOW)
.spawn();
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("pkill").args(["-x", "donutbrowser"]).spawn();
}
}
-152
View File
@@ -1,152 +0,0 @@
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tauri::Emitter;
use tokio::sync::Mutex;
use tokio_tungstenite::{connect_async, tungstenite::Message};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WsMessage {
#[serde(rename = "type")]
pub msg_type: String,
pub event: Option<String>,
pub payload: Option<serde_json::Value>,
}
pub struct DaemonClient {
app_handle: tauri::AppHandle,
connected: Arc<AtomicBool>,
shutdown: Arc<AtomicBool>,
daemon_port: Arc<Mutex<Option<u16>>>,
}
impl DaemonClient {
pub fn new(app_handle: tauri::AppHandle) -> Self {
Self {
app_handle,
connected: Arc::new(AtomicBool::new(false)),
shutdown: Arc::new(AtomicBool::new(false)),
daemon_port: Arc::new(Mutex::new(None)),
}
}
pub fn is_connected(&self) -> bool {
self.connected.load(Ordering::SeqCst)
}
pub async fn connect(&self, port: u16) -> Result<(), String> {
*self.daemon_port.lock().await = Some(port);
let url = format!("ws://127.0.0.1:{}/ws/events", port);
log::info!("[daemon-client] Connecting to daemon at {}", url);
let (ws_stream, _) = connect_async(&url)
.await
.map_err(|e| format!("Failed to connect to daemon: {}", e))?;
self.connected.store(true, Ordering::SeqCst);
log::info!("[daemon-client] Connected to daemon");
let (mut write, mut read) = ws_stream.split();
let app_handle = self.app_handle.clone();
let connected = self.connected.clone();
let shutdown = self.shutdown.clone();
// Spawn task to handle incoming messages
tokio::spawn(async move {
while !shutdown.load(Ordering::SeqCst) {
match read.next().await {
Some(Ok(Message::Text(text))) => {
if let Ok(ws_msg) = serde_json::from_str::<WsMessage>(&text) {
match ws_msg.msg_type.as_str() {
"event" => {
if let (Some(event), Some(payload)) = (ws_msg.event, ws_msg.payload) {
// Forward event to Tauri frontend
if let Err(e) = app_handle.emit(&event, payload) {
log::error!("[daemon-client] Failed to emit event: {}", e);
}
}
}
"connected" => {
log::info!("[daemon-client] Received connection confirmation");
}
"pong" => {
log::debug!("[daemon-client] Received pong");
}
_ => {
log::debug!("[daemon-client] Unknown message type: {}", ws_msg.msg_type);
}
}
}
}
Some(Ok(Message::Ping(data))) => {
log::debug!("[daemon-client] Received ping");
if let Err(e) = write.send(Message::Pong(data)).await {
log::error!("[daemon-client] Failed to send pong: {}", e);
break;
}
}
Some(Ok(Message::Close(_))) => {
log::info!("[daemon-client] Daemon closed connection");
break;
}
Some(Err(e)) => {
log::error!("[daemon-client] WebSocket error: {}", e);
break;
}
None => {
log::info!("[daemon-client] WebSocket stream ended");
break;
}
_ => {}
}
}
connected.store(false, Ordering::SeqCst);
log::info!("[daemon-client] Disconnected from daemon");
});
Ok(())
}
pub fn disconnect(&self) {
self.shutdown.store(true, Ordering::SeqCst);
self.connected.store(false, Ordering::SeqCst);
}
}
pub async fn start_daemon_connection(app_handle: tauri::AppHandle, port: u16) -> DaemonClient {
let client = DaemonClient::new(app_handle);
if let Err(e) = client.connect(port).await {
log::error!("[daemon-client] Failed to connect: {}", e);
}
client
}
pub async fn find_and_connect_to_daemon(app_handle: tauri::AppHandle) -> Option<DaemonClient> {
// Try default port first
let default_port = 10108;
log::info!(
"[daemon-client] Looking for daemon on port {}",
default_port
);
let client = DaemonClient::new(app_handle);
match client.connect(default_port).await {
Ok(()) => Some(client),
Err(e) => {
log::warn!(
"[daemon-client] Could not connect to daemon on default port: {}",
e
);
None
}
}
}
-360
View File
@@ -1,360 +0,0 @@
// Daemon Spawn - Start the daemon from the GUI
// Currently disabled; will be re-enabled in the future
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::thread;
use std::time::Duration;
use crate::daemon::autostart;
/// Check if a process with the given PID exists using the Windows API.
/// This avoids spawning tasklist.exe which causes a visible conhost window flash.
#[cfg(windows)]
fn win_process_exists(pid: u32) -> bool {
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
extern "system" {
fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut ();
fn CloseHandle(hObject: *mut ()) -> i32;
}
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
false
} else {
unsafe { CloseHandle(handle) };
true
}
}
#[derive(Debug, Deserialize, Default)]
struct DaemonState {
daemon_pid: Option<u32>,
}
fn get_state_path() -> PathBuf {
autostart::get_data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("daemon-state.json")
}
fn read_state() -> DaemonState {
let path = get_state_path();
if path.exists() {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(state) = serde_json::from_str(&content) {
return state;
}
}
}
DaemonState::default()
}
pub fn is_daemon_running() -> bool {
let state = read_state();
if let Some(pid) = state.daemon_pid {
#[cfg(unix)]
{
unsafe { libc::kill(pid as i32, 0) == 0 }
}
#[cfg(windows)]
{
win_process_exists(pid)
}
#[cfg(not(any(unix, windows)))]
{
false
}
} else {
false
}
}
#[cfg(target_os = "macos")]
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() {
let exe_dir = current_exe.parent()?;
// Check for daemon binary in same directory
#[cfg(target_os = "windows")]
let daemon_name = "donut-daemon.exe";
#[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);
}
}
// Try to find it in PATH
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
if let Ok(output) = Command::new("where")
.arg("donut-daemon")
.creation_flags(CREATE_NO_WINDOW)
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout);
let path = path.lines().next()?.trim();
return Some(PathBuf::from(path));
}
}
}
#[cfg(target_os = "linux")]
{
if let Ok(output) = Command::new("which").arg("donut-daemon").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout);
let path = path.trim();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
}
}
None
}
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 (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);
// 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")]
{
spawn_daemon_macos()?;
}
// On Linux, use direct spawn
#[cfg(target_os = "linux")]
{
spawn_daemon_unix()?;
}
#[cfg(windows)]
{
spawn_daemon_windows()?;
}
// Wait for daemon to start (max 3 seconds)
for i in 0..30 {
thread::sleep(Duration::from_millis(100));
if is_daemon_running() {
log::info!("Daemon started successfully after {}ms", (i + 1) * 100);
return Ok(());
}
}
// Check if we got a state file at least
let state = read_state();
if let Some(pid) = state.daemon_pid {
log::info!("Daemon appears to have started (PID {} in state file)", pid);
return Ok(());
}
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");
// Also explicitly start the agent in case it was already loaded but stopped
if let Err(e) = autostart::start_launch_agent() {
log::debug!("launchctl start note (non-fatal): {}", e);
}
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()?;
}
Ok(())
}
pub fn register_gui_pid() {
let path = get_state_path();
let mut val: serde_json::Value = if path.exists() {
fs::read_to_string(&path)
.ok()
.and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_else(|| serde_json::json!({}))
} else {
serde_json::json!({})
};
if let Some(obj) = val.as_object_mut() {
obj.insert(
"gui_pid".to_string(),
serde_json::Value::Number(std::process::id().into()),
);
}
if let Ok(content) = serde_json::to_string_pretty(&val) {
let _ = fs::write(&path, content);
}
}
-134
View File
@@ -1,134 +0,0 @@
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::IntoResponse,
};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::events::{DaemonEmitter, DaemonEvent};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WsMessage {
#[serde(rename = "type")]
pub msg_type: String,
pub event: Option<String>,
pub payload: Option<serde_json::Value>,
}
#[derive(Clone)]
pub struct WsState {
event_emitter: Option<Arc<DaemonEmitter>>,
}
impl WsState {
pub fn new() -> Self {
Self {
event_emitter: None,
}
}
pub fn with_emitter(emitter: Arc<DaemonEmitter>) -> Self {
Self {
event_emitter: Some(emitter),
}
}
}
impl Default for WsState {
fn default() -> Self {
Self::new()
}
}
pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<WsState>) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
async fn handle_socket(socket: WebSocket, state: WsState) {
let (mut sender, mut receiver) = socket.split();
// Subscribe to daemon events if emitter is available
let mut event_rx = state.event_emitter.as_ref().map(|e| e.subscribe());
log::info!("[ws] Client connected");
// Send initial ping to confirm connection
let ping_msg = WsMessage {
msg_type: "connected".to_string(),
event: None,
payload: None,
};
if let Ok(msg_str) = serde_json::to_string(&ping_msg) {
let _ = sender.send(Message::Text(msg_str.into())).await;
}
loop {
tokio::select! {
// Handle incoming messages from client
Some(msg) = receiver.next() => {
match msg {
Ok(Message::Text(text)) => {
if let Ok(ws_msg) = serde_json::from_str::<WsMessage>(&text) {
match ws_msg.msg_type.as_str() {
"ping" => {
let pong = WsMessage {
msg_type: "pong".to_string(),
event: None,
payload: None,
};
if let Ok(msg_str) = serde_json::to_string(&pong) {
let _ = sender.send(Message::Text(msg_str.into())).await;
}
}
_ => {
log::debug!("[ws] Received unknown message type: {}", ws_msg.msg_type);
}
}
}
}
Ok(Message::Ping(data)) => {
let _ = sender.send(Message::Pong(data)).await;
}
Ok(Message::Close(_)) => {
log::info!("[ws] Client disconnected");
break;
}
Err(e) => {
log::error!("[ws] Error receiving message: {}", e);
break;
}
_ => {}
}
}
// Forward daemon events to client
Some(daemon_event) = async {
if let Some(ref mut rx) = event_rx {
rx.recv().await.ok()
} else {
std::future::pending::<Option<DaemonEvent>>().await
}
} => {
let ws_msg = WsMessage {
msg_type: "event".to_string(),
event: Some(daemon_event.event_type),
payload: Some(daemon_event.payload),
};
if let Ok(msg_str) = serde_json::to_string(&ws_msg) {
if sender.send(Message::Text(msg_str.into())).await.is_err() {
log::error!("[ws] Failed to send event to client");
break;
}
}
}
else => break,
}
}
log::info!("[ws] WebSocket connection closed");
}
+64 -12
View File
@@ -1296,21 +1296,73 @@ pub async fn ensure_active_browsers_downloaded(
};
log::info!("Auto-downloading {browser} {version} (no versions found locally)");
match crate::downloader::download_browser(
app_handle.clone(),
browser.to_string(),
version.clone(),
)
.await
{
Ok(_) => {
downloaded.push(format!("{browser} {version}"));
log::info!("Successfully auto-downloaded {browser} {version}");
// Retry transient failures a few times. Each attempt is wrapped in an overall
// timeout so that a hang anywhere in the download pipeline (version resolution,
// a stalled stream, extraction) cannot block the next browser forever. This is
// the core of the bug fix: Wayfern going first must never starve Camoufox.
const MAX_ATTEMPTS: u32 = 3;
const ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
let mut succeeded = false;
for attempt in 1..=MAX_ATTEMPTS {
let result = tokio::time::timeout(
ATTEMPT_TIMEOUT,
crate::downloader::download_browser(
app_handle.clone(),
browser.to_string(),
version.clone(),
),
)
.await;
match result {
Ok(Ok(_)) => {
downloaded.push(format!("{browser} {version}"));
log::info!("Successfully auto-downloaded {browser} {version}");
succeeded = true;
break;
}
Ok(Err(e)) => {
log::warn!(
"Failed to auto-download {browser} {version} (attempt {attempt}/{MAX_ATTEMPTS}): {e}"
);
}
Err(_) => {
// The download future itself hung past the overall timeout and was dropped,
// so its own cleanup never ran. Clear any leftover in-progress bookkeeping
// (the future may have re-resolved to a different version, so clear by
// browser prefix) and emit a terminal error event so the UI stops spinning.
log::warn!(
"Auto-download of {browser} {version} timed out after {}s (attempt {attempt}/{MAX_ATTEMPTS})",
ATTEMPT_TIMEOUT.as_secs()
);
crate::downloader::clear_download_state_for_browser(browser);
let progress = crate::downloader::DownloadProgress {
browser: (*browser).to_string(),
version: version.clone(),
downloaded_bytes: 0,
total_bytes: None,
percentage: 0.0,
speed_bytes_per_sec: 0.0,
eta_seconds: None,
stage: "error".to_string(),
};
let _ = crate::events::emit("download-progress", &progress);
}
}
Err(e) => {
log::warn!("Failed to auto-download {browser} {version}: {e}");
if attempt < MAX_ATTEMPTS {
// Short backoff before retrying a transient failure.
let backoff = std::time::Duration::from_secs(2u64.pow(attempt - 1));
tokio::time::sleep(backoff).await;
}
}
if !succeeded {
// Do NOT abort the whole routine: continue so the next browser (Camoufox)
// still gets its chance even though this one failed/timed out.
log::warn!("Giving up on auto-download of {browser} {version} after {MAX_ATTEMPTS} attempts");
}
}
Ok(downloaded)
+125 -15
View File
@@ -10,6 +10,11 @@ use crate::browser::{create_browser, BrowserType};
use crate::browser_version_manager::DownloadInfo;
use crate::events;
// Maximum time to wait for the next chunk of a streaming download before treating
// the connection as stalled. Converts an indefinite hang into a terminal error so
// the UI can surface it and the caller can move on / retry.
const STREAM_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
// Global state to track currently downloading browser-version pairs
lazy_static::lazy_static! {
static ref DOWNLOADING_BROWSERS: std::sync::Arc<Mutex<std::collections::HashSet<String>>> =
@@ -44,6 +49,11 @@ impl Downloader {
Self {
client: Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
// Per-read idle timeout: if the connection stalls mid-stream with no bytes
// for this long, the read fails instead of hanging forever. This is the
// transport-level guard; the streaming loop also wraps each read in an
// explicit tokio timeout as defense-in-depth.
.read_timeout(STREAM_IDLE_TIMEOUT)
.build()
.unwrap_or_else(|_| Client::new()),
api_client: ApiClient::instance(),
@@ -470,7 +480,26 @@ impl Downloader {
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
loop {
// Wrap each read in an idle timeout so a stalled connection (no bytes flowing)
// surfaces as a terminal error instead of awaiting forever.
let next = match tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()).await {
Ok(item) => item,
Err(_) => {
drop(file);
// Keep any partial bytes on disk so a later attempt can resume via Range.
return Err(
format!(
"Download stalled: no data received for {}s",
STREAM_IDLE_TIMEOUT.as_secs()
)
.into(),
);
}
};
let Some(chunk) = next else {
break;
};
if let Some(token) = cancel_token {
if token.is_cancelled() {
drop(file);
@@ -694,20 +723,25 @@ impl Downloader {
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);
}
// Emit a terminal stage so the UI stops spinning. A user cancellation maps to
// "cancelled"; any other failure (network error, stall timeout, bad status)
// maps to "error" so the frontend can show a concrete error toast.
let stage = if cancel_token.is_cancelled() {
"cancelled"
} else {
"error"
};
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: stage.to_string(),
};
let _ = events::emit("download-progress", &progress);
return Err(format!("Failed to download browser: {e}").into());
}
@@ -844,6 +878,20 @@ impl Downloader {
// Do not delete files on verification failure; keep archive for manual retry.
let _ = self.registry.remove_browser(&browser_str, &version);
let _ = self.registry.save();
// Emit a terminal error stage so the UI shows an error instead of spinning.
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: "error".to_string(),
};
let _ = events::emit("download-progress", &progress);
// Remove browser-version pair from downloading set on verification failure
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
@@ -979,6 +1027,25 @@ pub fn is_downloading(browser: &str, version: &str) -> bool {
downloading.contains(&download_key)
}
/// Clear all in-progress download bookkeeping for a browser.
///
/// Used as a last-resort cleanup when a download future is abandoned (e.g. dropped
/// by an outer timeout) before its own error path could run. Because
/// `download_browser_full` may re-resolve to a different version than requested, this
/// matches by the `"{browser}-"` key prefix rather than an exact version so no stuck
/// key is left behind regardless of which version was actually in flight.
pub fn clear_download_state_for_browser(browser: &str) {
let prefix = format!("{browser}-");
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.retain(|key| !key.starts_with(&prefix));
}
{
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.retain(|key, _| !key.starts_with(&prefix));
}
}
#[tauri::command]
pub async fn download_browser(
app_handle: tauri::AppHandle,
@@ -1110,6 +1177,49 @@ mod tests {
let downloaded_content = std::fs::read(&downloaded_file).unwrap();
assert_eq!(downloaded_content.len(), test_content.len());
}
#[test]
fn test_clear_download_state_for_browser_removes_stuck_keys() {
// Simulate a download future that was abandoned without running its own cleanup,
// leaving stuck bookkeeping for a version that differs from the requested one.
let key = "wayfern-1.2.3-resolved".to_string();
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.insert(key.clone());
}
{
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.insert(key.clone(), CancellationToken::new());
}
// A different browser's in-progress state must be left untouched.
let other = "camoufox-9.9.9".to_string();
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.insert(other.clone());
}
clear_download_state_for_browser("wayfern");
assert!(
!is_downloading("wayfern", "1.2.3-resolved"),
"stuck wayfern key should be cleared even when version differs from request"
);
{
let tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
assert!(
!tokens.contains_key(&key),
"stuck wayfern cancellation token should be cleared"
);
}
assert!(
is_downloading("camoufox", "9.9.9"),
"unrelated browser's download state must be preserved"
);
// Cleanup so we don't leak global state into other tests.
clear_download_state_for_browser("camoufox");
}
}
// Global singleton instance
+1
View File
@@ -281,6 +281,7 @@ mod tests {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
}
}
+2 -73
View File
@@ -1,10 +1,7 @@
use serde::Serialize;
use std::sync::Arc;
use tokio::sync::broadcast;
/// Trait for emitting events to the frontend or connected clients.
/// This abstraction allows the same code to work in both GUI (Tauri) mode
/// and daemon mode (WebSocket broadcast).
/// Trait for emitting events to the frontend.
///
/// Note: This trait uses `serde_json::Value` to be dyn-compatible.
/// Use the convenience functions `emit()` and `emit_empty()` which accept
@@ -37,49 +34,6 @@ impl EventEmitter for TauriEmitter {
}
}
/// Event message sent through the daemon's broadcast channel.
#[derive(Clone, Debug)]
pub struct DaemonEvent {
pub event_type: String,
pub payload: serde_json::Value,
}
/// Daemon-based event emitter for background daemon mode.
/// Broadcasts events to all connected WebSocket clients.
#[derive(Clone)]
pub struct DaemonEmitter {
tx: broadcast::Sender<DaemonEvent>,
}
impl DaemonEmitter {
pub fn new(tx: broadcast::Sender<DaemonEvent>) -> Self {
Self { tx }
}
/// Create a new DaemonEmitter with a default channel capacity.
pub fn with_capacity(capacity: usize) -> (Self, broadcast::Receiver<DaemonEvent>) {
let (tx, rx) = broadcast::channel(capacity);
(Self { tx }, rx)
}
/// Subscribe to events from this emitter.
pub fn subscribe(&self) -> broadcast::Receiver<DaemonEvent> {
self.tx.subscribe()
}
}
impl EventEmitter for DaemonEmitter {
fn emit_value(&self, event: &str, payload: serde_json::Value) -> Result<(), String> {
let daemon_event = DaemonEvent {
event_type: event.to_string(),
payload,
};
// Ignore send errors (no receivers connected)
let _ = self.tx.send(daemon_event);
Ok(())
}
}
/// No-op emitter for testing or when events are not needed.
#[derive(Clone, Default)]
pub struct NoopEmitter;
@@ -91,8 +45,7 @@ impl EventEmitter for NoopEmitter {
}
/// Global event emitter that can be set at runtime.
/// This allows managers to emit events without knowing whether they're
/// running in GUI or daemon mode.
/// This allows managers to emit events without holding an AppHandle directly.
static GLOBAL_EMITTER: std::sync::OnceLock<Arc<dyn EventEmitter>> = std::sync::OnceLock::new();
/// Set the global event emitter. This should be called once during app startup.
@@ -136,30 +89,6 @@ mod tests {
.is_ok());
}
#[test]
fn test_daemon_emitter() {
let (emitter, mut rx) = DaemonEmitter::with_capacity(16);
// Emit an event
let _ = emitter.emit_value("test-event", serde_json::json!("hello"));
// Check we received it
let event = rx.try_recv().unwrap();
assert_eq!(event.event_type, "test-event");
assert_eq!(event.payload, serde_json::json!("hello"));
}
#[test]
fn test_daemon_emitter_no_receivers() {
let (tx, _) = broadcast::channel::<DaemonEvent>(16);
let emitter = DaemonEmitter::new(tx);
// Should not error even with no receivers
assert!(emitter
.emit_value("test-event", serde_json::json!("hello"))
.is_ok());
}
#[test]
fn test_emit_convenience_function() {
// Test that emit() works with various types
+8
View File
@@ -13,6 +13,10 @@ pub struct ProfileGroup {
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
/// Unix seconds of the last meaningful user edit. Source of truth for sync
/// conflict resolution (last-write-wins); bumped on edits only.
#[serde(default)]
pub updated_at: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -90,6 +94,7 @@ impl GroupManager {
name,
sync_enabled,
last_sync: None,
updated_at: Some(crate::proxy_manager::now_secs()),
};
groups_data.groups.push(group.clone());
@@ -136,6 +141,7 @@ impl GroupManager {
.ok_or_else(|| format!("Group with id '{id}' not found"))?;
group.name = name;
group.updated_at = Some(crate::proxy_manager::now_secs());
let updated_group = group.clone();
self.save_groups_data(&groups_data)?;
@@ -167,6 +173,7 @@ impl GroupManager {
existing.name = group.name.clone();
existing.sync_enabled = group.sync_enabled;
existing.last_sync = group.last_sync;
existing.updated_at = group.updated_at;
self.save_groups_data(&groups_data)?;
}
@@ -183,6 +190,7 @@ impl GroupManager {
existing.name = group.name.clone();
existing.sync_enabled = group.sync_enabled;
existing.last_sync = group.last_sync;
existing.updated_at = group.updated_at;
} else {
groups_data.groups.push(group.clone());
}
+171 -84
View File
@@ -52,11 +52,6 @@ mod wayfern_terms;
pub mod cloud_auth;
mod commercial_license;
mod cookie_manager;
pub mod daemon;
pub mod daemon_client;
#[allow(dead_code)]
mod daemon_spawn;
pub mod daemon_ws;
pub mod events;
mod mcp_integrations;
mod mcp_server;
@@ -98,10 +93,10 @@ use downloaded_browsers_registry::{
use downloader::{cancel_download, download_browser};
use settings_manager::{
decline_launch_on_login, dismiss_window_resize_warning, enable_launch_on_login, get_app_settings,
complete_onboarding, dismiss_window_resize_warning, get_app_settings, get_onboarding_completed,
get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings,
get_window_resize_warning_dismissed, open_log_directory, read_log_files, save_app_settings,
save_sync_settings, save_table_sorting_settings, should_show_launch_on_login_prompt,
save_sync_settings, save_table_sorting_settings,
};
use sync::{
@@ -196,7 +191,8 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
}
}
#[tauri::command]
// Called internally for deep-link / startup URL handling — not invoked from the
// frontend, so it is intentionally not a `#[tauri::command]`.
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
log::info!("handle_url_open called with URL: {url}");
@@ -933,15 +929,21 @@ async fn update_vpn_config(vpn_id: String, name: String) -> Result<vpn::VpnConfi
#[tauri::command]
async fn check_vpn_validity(
vpn_id: String,
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
check_vpn_validity_core(&vpn_id).await
}
pub async fn check_vpn_validity_core(
vpn_id: &str,
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let had_existing_worker = vpn_worker_storage::find_vpn_worker_by_vpn_id(&vpn_id).is_some();
let had_existing_worker = vpn_worker_storage::find_vpn_worker_by_vpn_id(vpn_id).is_some();
let vpn_worker = vpn_worker_runner::start_vpn_worker(&vpn_id)
let vpn_worker = vpn_worker_runner::start_vpn_worker(vpn_id)
.await
.map_err(|e| format!("Failed to start VPN worker: {e}"))?;
@@ -1018,6 +1020,53 @@ async fn check_vpn_validity(
Ok(result)
}
/// Validate that a profile's selected proxy or VPN actually works before the
/// profile is created. Shared by the Tauri command, REST API, and MCP create
/// paths so a dead/unreachable proxy or VPN (or a 402 from an expired proxy
/// subscription) fails creation identically everywhere. Returns structured
/// `{ "code": ... }` error strings the frontend translates via backend-errors.ts.
pub async fn validate_profile_network(
proxy_id: Option<&str>,
vpn_id: Option<&str>,
) -> Result<(), String> {
if let Some(vpn_id) = vpn_id.filter(|s| !s.is_empty()) {
let result = check_vpn_validity_core(vpn_id).await?;
if !result.is_valid {
return Err(serde_json::json!({ "code": "VPN_NOT_WORKING" }).to_string());
}
return Ok(());
}
if let Some(proxy_id) = proxy_id.filter(|s| !s.is_empty()) {
// The cloud-included proxy is managed infrastructure; its only failure mode
// is the user hitting their usage limit, which surfaces as a 402 at request
// time. There's nothing to pre-validate here.
if proxy_id == crate::proxy_manager::CLOUD_PROXY_ID {
return Ok(());
}
let settings = crate::proxy_manager::PROXY_MANAGER
.get_proxy_settings_by_id(proxy_id)
.ok_or_else(|| format!("Proxy '{proxy_id}' not found"))?;
match crate::proxy_manager::PROXY_MANAGER
.check_proxy_validity(proxy_id, &settings)
.await
{
Ok(result) if result.is_valid => {}
Ok(_) => {
return Err(serde_json::json!({ "code": "PROXY_NOT_WORKING" }).to_string());
}
Err(err) if err.contains("402") => {
return Err(serde_json::json!({ "code": "PROXY_PAYMENT_REQUIRED" }).to_string());
}
Err(_) => {
return Err(serde_json::json!({ "code": "PROXY_NOT_WORKING" }).to_string());
}
}
}
Ok(())
}
#[tauri::command]
async fn connect_vpn(vpn_id: String) -> Result<(), String> {
// Start VPN worker process (detached, survives GUI shutdown)
@@ -1126,6 +1175,7 @@ async fn generate_sample_fingerprint(
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
if browser == "camoufox" {
@@ -1175,6 +1225,96 @@ fn show_main_window(app_handle: &tauri::AppHandle) {
}
}
/// Update the tray menu labels with localized strings pushed from the frontend
/// (which owns the active language). The item ids are unchanged so the existing
/// menu-event handler keeps matching.
#[tauri::command]
fn update_tray_menu(
app_handle: tauri::AppHandle,
show_label: String,
quit_label: String,
) -> Result<(), String> {
use tauri::menu::{MenuBuilder, MenuItemBuilder};
if let Some(tray) = app_handle.tray_by_id("main") {
let show_item = MenuItemBuilder::with_id("tray_show", show_label)
.build(&app_handle)
.map_err(|e| e.to_string())?;
let quit_item = MenuItemBuilder::with_id("tray_quit", quit_label)
.build(&app_handle)
.map_err(|e| e.to_string())?;
let menu = MenuBuilder::new(&app_handle)
.item(&show_item)
.separator()
.item(&quit_item)
.build()
.map_err(|e| e.to_string())?;
tray.set_menu(Some(menu)).map_err(|e| e.to_string())?;
}
Ok(())
}
/// Build the system tray. Best-effort: on Linux the tray depends on
/// libayatana-appindicator at runtime, so any failure here must not abort app
/// startup — the caller logs and continues without a tray.
fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
use std::sync::atomic::Ordering;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
// Bootstrap labels only — the frontend pushes localized labels via
// `update_tray_menu` on mount and on language change, and the menu is only
// opened after a minimize-to-tray (post-mount), so these are never shown.
let show_item = MenuItemBuilder::with_id("tray_show", "Show Donut Browser").build(app)?;
let quit_item = MenuItemBuilder::with_id("tray_quit", "Quit").build(app)?;
let tray_menu = MenuBuilder::new(app)
.item(&show_item)
.separator()
.item(&quit_item)
.build()?;
// macOS uses a black template icon (the OS tints it for light/dark menu
// bars). Windows and Linux use the full-color icon, because neither tints a
// template — a black template would be invisible on dark Linux panels.
#[cfg(target_os = "macos")]
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-44.png");
#[cfg(not(target_os = "macos"))]
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-win-44.png");
let tray_rgba = image::load_from_memory(tray_icon_bytes)?.into_rgba8();
let (tray_w, tray_h) = tray_rgba.dimensions();
let tray_image = tauri::image::Image::new_owned(tray_rgba.into_raw(), tray_w, tray_h);
TrayIconBuilder::with_id("main")
.icon(tray_image)
.icon_as_template(cfg!(target_os = "macos"))
.tooltip("Donut Browser")
.menu(&tray_menu)
.show_menu_on_left_click(false)
.on_menu_event(|app_handle, event| match event.id().as_ref() {
"tray_show" => show_main_window(app_handle),
"tray_quit" => {
QUIT_CONFIRMED.store(true, Ordering::SeqCst);
app_handle.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
// Click events are not delivered on Linux (AppIndicator/SNI only drives
// the menu), so left-click-to-restore is macOS/Windows only — Linux users
// restore via the "Show Donut Browser" menu item.
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main_window(tray.app_handle());
}
})
.build(app)?;
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let args: Vec<String> = env::args().collect();
@@ -1188,15 +1328,25 @@ pub fn run() {
let log_file_name = app_dirs::app_name();
// Honor DONUTBROWSER_DATA_ROOT: when set, logs go to <root>/logs instead of
// the platform default app log dir, so all on-disk state lives under one root.
let file_log_target = match app_dirs::log_dir_override() {
Some(path) => Target::new(TargetKind::Folder {
path,
file_name: Some(log_file_name.to_string()),
}),
None => Target::new(TargetKind::LogDir {
file_name: Some(log_file_name.to_string()),
}),
};
tauri::Builder::default()
.plugin(
tauri_plugin_log::Builder::new()
.clear_targets() // Clear default targets to avoid duplicates
.target(Target::new(TargetKind::Stdout))
.target(Target::new(TargetKind::Webview))
.target(Target::new(TargetKind::LogDir {
file_name: Some(log_file_name.to_string()),
}))
.target(file_log_target)
// 5 MB per rotated file × KeepAll — the previous 100 KB limit
// truncated useful context in customer support reports; 50 MB
// turned out to be excessive disk pressure.
@@ -1248,14 +1398,6 @@ pub fn run() {
mgr.ensure_icons_extracted();
}
// Daemon (tray icon) is currently disabled — clean up any existing autostart
if daemon::autostart::is_autostart_enabled() {
log::info!("Removing daemon autostart (daemon is disabled)");
if let Err(e) = daemon::autostart::disable_autostart() {
log::warn!("Failed to remove daemon autostart: {e}");
}
}
// Create the main window programmatically
#[allow(unused_variables)]
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
@@ -1274,65 +1416,11 @@ pub fn run() {
let window = win_builder.build().unwrap();
// System tray so the user can keep the app running after the close
// dialog's "Minimize" action hides the window.
{
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
let show_item = MenuItemBuilder::with_id("tray_show", "Show Donut Browser")
.build(app)
.map_err(|e| format!("Failed to build tray show item: {e}"))?;
let quit_item = MenuItemBuilder::with_id("tray_quit", "Quit")
.build(app)
.map_err(|e| format!("Failed to build tray quit item: {e}"))?;
let tray_menu = MenuBuilder::new(app)
.item(&show_item)
.separator()
.item(&quit_item)
.build()
.map_err(|e| format!("Failed to build tray menu: {e}"))?;
// Tray-specific icons. macOS/Linux get a template (black + alpha)
// version so the OS can tint it for light/dark menu bars; Windows
// gets the full-color variant. Decode through the `image` crate so
// we hand Tauri raw RGBA — `Image::from_bytes` can fail silently on
// bitmaps that don't match the size Tauri expects.
#[cfg(target_os = "windows")]
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-win-44.png");
#[cfg(not(target_os = "windows"))]
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-44.png");
let tray_rgba = image::load_from_memory(tray_icon_bytes)
.map_err(|e| format!("Failed to decode tray icon: {e}"))?
.into_rgba8();
let (tray_w, tray_h) = tray_rgba.dimensions();
let tray_image = tauri::image::Image::new_owned(tray_rgba.into_raw(), tray_w, tray_h);
let _tray = TrayIconBuilder::with_id("main")
.icon(tray_image)
.icon_as_template(cfg!(not(target_os = "windows")))
.tooltip("Donut Browser")
.menu(&tray_menu)
.show_menu_on_left_click(false)
.on_menu_event(|app_handle, event| match event.id().as_ref() {
"tray_show" => show_main_window(app_handle),
"tray_quit" => {
QUIT_CONFIRMED.store(true, Ordering::SeqCst);
app_handle.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main_window(tray.app_handle());
}
})
.build(app)
.map_err(|e| format!("Failed to build tray icon: {e}"))?;
// dialog's "Minimize" action hides the window. Best-effort: a tray
// failure (e.g. missing libayatana-appindicator on Linux) must never
// prevent the app from launching, so we log and continue without it.
if let Err(e) = setup_system_tray(app.handle()) {
log::warn!("System tray unavailable, continuing without it: {e}");
}
// Intercept the window close so the frontend can ask the user whether
@@ -2066,6 +2154,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
confirm_quit,
hide_to_tray,
update_tray_menu,
get_supported_browsers,
is_browser_supported_on_platform,
download_browser,
@@ -2096,15 +2185,14 @@ pub fn run() {
save_app_settings,
read_log_files,
open_log_directory,
should_show_launch_on_login_prompt,
enable_launch_on_login,
decline_launch_on_login,
get_table_sorting_settings,
save_table_sorting_settings,
get_system_language,
get_system_info,
dismiss_window_resize_warning,
get_window_resize_warning_dismissed,
get_onboarding_completed,
complete_onboarding,
clear_all_version_cache_and_refetch,
is_default_browser,
open_url_with_profile,
@@ -2216,7 +2304,6 @@ pub fn run() {
disconnect_vpn,
get_vpn_status,
list_active_vpn_connections,
handle_url_open,
// Cloud auth commands
cloud_auth::cloud_exchange_device_code,
cloud_auth::cloud_get_user,
+25 -17
View File
@@ -1671,9 +1671,15 @@ impl McpServer {
"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,
// Fingerprint management
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(arguments).await,
"update_profile_fingerprint" => self.handle_update_profile_fingerprint(arguments).await,
// Fingerprint management — viewing and editing both require a paid plan.
"get_profile_fingerprint" => {
Self::require_paid_subscription("Fingerprint").await?;
self.handle_get_profile_fingerprint(arguments).await
}
"update_profile_fingerprint" => {
Self::require_paid_subscription("Fingerprint").await?;
self.handle_update_profile_fingerprint(arguments).await
}
"update_profile_proxy_bypass_rules" => {
self
.handle_update_profile_proxy_bypass_rules(arguments)
@@ -1832,7 +1838,7 @@ impl McpServer {
})?;
let url = arguments.get("url").and_then(|v| v.as_str());
let _headless = arguments
let headless = arguments
.get("headless")
.and_then(|v| v.as_bool())
.unwrap_or(false);
@@ -1876,19 +1882,21 @@ impl McpServer {
message: "MCP server not properly initialized".to_string(),
})?;
// Launch the browser
crate::browser_runner::BrowserRunner::instance()
.launch_browser(
app_handle.clone(),
profile,
url.map(|s| s.to_string()),
None,
)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to launch browser: {e}"),
})?;
// Launch a fresh instance, honoring the requested headless mode. The CDP
// port is self-allocated and discovered later via get_cdp_port_for_profile.
crate::browser_runner::launch_browser_profile_impl(
app_handle.clone(),
profile.clone(),
url.map(|s| s.to_string()),
None,
headless,
true,
)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to launch browser: {e}"),
})?;
Ok(serde_json::json!({
"content": [{
+20 -2
View File
@@ -200,6 +200,7 @@ impl ProfileManager {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -303,6 +304,7 @@ impl ProfileManager {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -365,6 +367,7 @@ impl ProfileManager {
.map(|d| d.as_secs())
.unwrap_or(0),
),
updated_at: Some(crate::proxy_manager::now_secs()),
};
// Save profile info
@@ -510,6 +513,7 @@ impl ProfileManager {
// Update profile name (no need to move directories since we use UUID)
profile.name = new_name.to_string();
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save profile with new name
self.save_profile(&profile)?;
@@ -719,6 +723,7 @@ impl ProfileManager {
}
profile.group_id = group_id.clone();
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
@@ -773,6 +778,7 @@ impl ProfileManager {
}
}
profile.tags = deduped;
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save profile
self.save_profile(&profile)?;
@@ -809,6 +815,7 @@ impl ProfileManager {
// Update note (trim whitespace, set to None if empty)
profile.note = note.map(|n| n.trim().to_string()).filter(|n| !n.is_empty());
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save profile
self.save_profile(&profile)?;
@@ -838,6 +845,7 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.launch_hook = Self::normalize_launch_hook(launch_hook)?;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
@@ -869,6 +877,7 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.proxy_bypass_rules = rules;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
@@ -895,6 +904,7 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.dns_blocklist = dns_blocklist;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
@@ -1058,6 +1068,7 @@ impl ProfileManager {
.map(|d| d.as_secs())
.unwrap_or(0),
),
updated_at: Some(crate::proxy_manager::now_secs()),
};
self.save_profile(&new_profile)?;
@@ -1225,6 +1236,7 @@ impl ProfileManager {
// Update proxy settings and clear VPN (mutual exclusion)
profile.proxy_id = proxy_id.clone();
profile.vpn_id = None;
profile.updated_at = Some(crate::proxy_manager::now_secs());
// Save the updated profile
self
@@ -1324,6 +1336,7 @@ impl ProfileManager {
// Update VPN and clear proxy (mutual exclusion)
profile.vpn_id = vpn_id.clone();
profile.proxy_id = None;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self
.save_profile(&profile)
@@ -1368,6 +1381,7 @@ impl ProfileManager {
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
profile.extension_group_id = extension_group_id.clone();
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
@@ -2455,6 +2469,10 @@ pub async fn create_browser_profile_new(
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
}
// A dead/unreachable proxy or VPN (or a 402 from an expired proxy
// subscription) cancels creation with a translatable error.
crate::validate_profile_network(proxy_id.as_deref(), vpn_id.as_deref()).await?;
let browser_type =
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
create_browser_profile_with_group(
@@ -2486,7 +2504,7 @@ pub async fn update_camoufox_config(
.has_active_paid_subscription()
.await
{
return Err("Fingerprint editing requires an active Pro subscription".to_string());
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
}
if !crate::cloud_auth::CLOUD_AUTH
@@ -2514,7 +2532,7 @@ pub async fn update_wayfern_config(
.has_active_paid_subscription()
.await
{
return Err("Fingerprint editing requires an active Pro subscription".to_string());
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
}
if !crate::cloud_auth::CLOUD_AUTH
+6
View File
@@ -78,6 +78,12 @@ pub struct BrowserProfile {
/// any staleness check.
#[serde(default)]
pub created_at: Option<u64>,
/// Unix seconds of the last meaningful metadata edit (name, tags, note,
/// proxy/vpn/group/extension assignment, launch hook, bypass rules, dns).
/// Source of truth for metadata sync conflict resolution (last-write-wins);
/// NOT bumped by browser-file changes, which sync via the file manifest.
#[serde(default)]
pub updated_at: Option<u64>,
}
pub fn default_release_type() -> String {
+3
View File
@@ -586,6 +586,7 @@ impl ProfileImporter {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -668,6 +669,7 @@ impl ProfileImporter {
dns_blocklist: None,
password_protected: false,
created_at: None,
updated_at: None,
};
match self
@@ -726,6 +728,7 @@ impl ProfileImporter {
.map(|d| d.as_secs())
.unwrap_or(0),
),
updated_at: Some(crate::proxy_manager::now_secs()),
};
self.profile_manager.save_profile(&profile)?;
+20
View File
@@ -103,6 +103,11 @@ pub struct StoredProxy {
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
/// Unix seconds of the last meaningful user edit. Source of truth for sync
/// conflict resolution (last-write-wins) — bumped on config edits only, never
/// by sync bookkeeping. `None` on legacy files is treated as 0.
#[serde(default)]
pub updated_at: Option<u64>,
#[serde(default)]
pub is_cloud_managed: bool,
#[serde(default)]
@@ -124,6 +129,14 @@ pub struct StoredProxy {
pub dynamic_proxy_format: Option<String>,
}
/// Current unix time in whole seconds. Used to stamp `updated_at` on edits.
pub fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
impl StoredProxy {
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
let sync_enabled = crate::sync::is_sync_configured();
@@ -133,6 +146,7 @@ impl StoredProxy {
proxy_settings,
sync_enabled,
last_sync: None,
updated_at: Some(now_secs()),
is_cloud_managed: false,
is_cloud_derived: false,
geo_country: None,
@@ -159,10 +173,12 @@ impl StoredProxy {
pub fn update_settings(&mut self, proxy_settings: ProxySettings) {
self.proxy_settings = proxy_settings;
self.updated_at = Some(now_secs());
}
pub fn update_name(&mut self, name: String) {
self.name = name;
self.updated_at = Some(now_secs());
}
}
@@ -455,6 +471,7 @@ impl ProxyManager {
proxy_settings,
sync_enabled: false,
last_sync: None,
updated_at: Some(now_secs()),
is_cloud_managed: true,
is_cloud_derived: false,
geo_country: None,
@@ -646,6 +663,7 @@ impl ProxyManager {
proxy_settings,
sync_enabled: false,
last_sync: None,
updated_at: Some(now_secs()),
is_cloud_managed: false,
is_cloud_derived: true,
geo_country: Some(country),
@@ -710,6 +728,7 @@ impl ProxyManager {
&proxy.geo_isp,
);
proxy.updated_at = Some(now_secs());
proxy.proxy_settings.username = Some(geo_username);
proxy.proxy_settings.password = base_proxy.proxy_settings.password.clone();
proxy.proxy_settings.host = base_proxy.proxy_settings.host.clone();
@@ -3154,6 +3173,7 @@ mod tests {
},
sync_enabled: false,
last_sync: None,
updated_at: None,
is_cloud_managed: false,
is_cloud_derived: false,
geo_country: Some("US".to_string()),
-1
View File
@@ -28,7 +28,6 @@ fn unsuffixed_binary_name(base_name: &str) -> String {
{
match base_name {
"donut-proxy" => "donut-proxy.exe".to_string(),
"donut-daemon" => "donut-daemon.exe".to_string(),
_ => String::new(),
}
}
+25 -61
View File
@@ -50,12 +50,12 @@ pub struct AppSettings {
#[serde(default)]
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", "ko", "ru", or None for system default
#[serde(default)]
pub window_resize_warning_dismissed: bool,
#[serde(default)]
pub onboarding_completed: bool, // First-launch onboarding has been shown/handled (one-shot)
#[serde(default)]
pub disable_auto_updates: bool,
/// When true, the decrypted in-RAM copy of a password-protected profile is
/// preserved between launches for faster subsequent startups. The on-disk
@@ -93,9 +93,9 @@ impl Default for AppSettings {
mcp_enabled: false,
mcp_port: None,
mcp_token: None,
launch_on_login_declined: false,
language: None,
window_resize_warning_dismissed: false,
onboarding_completed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
}
@@ -183,17 +183,6 @@ impl SettingsManager {
Ok(())
}
pub fn should_show_launch_on_login_prompt(&self) -> Result<bool, Box<dyn std::error::Error>> {
// Daemon is currently disabled, never show this prompt
Ok(false)
}
pub fn decline_launch_on_login(&self) -> Result<(), Box<dyn std::error::Error>> {
let mut settings = self.load_settings()?;
settings.launch_on_login_declined = true;
self.save_settings(&settings)
}
fn get_vault_password() -> String {
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
}
@@ -795,7 +784,6 @@ pub async fn save_app_settings(
if let Ok(content) = std::fs::read_to_string(manager.get_settings_file()) {
if let Ok(current) = serde_json::from_str::<AppSettings>(&content) {
settings.window_resize_warning_dismissed = current.window_resize_warning_dismissed;
settings.launch_on_login_declined = current.launch_on_login_declined;
}
}
@@ -919,28 +907,6 @@ pub async fn open_log_directory(app_handle: tauri::AppHandle) -> Result<(), Stri
Ok(())
}
#[tauri::command]
pub async fn should_show_launch_on_login_prompt() -> Result<bool, String> {
let manager = SettingsManager::instance();
manager
.should_show_launch_on_login_prompt()
.map_err(|e| format!("Failed to check launch on login prompt setting: {e}"))
}
#[tauri::command]
pub async fn enable_launch_on_login() -> Result<(), String> {
crate::daemon::autostart::enable_autostart()
.map_err(|e| format!("Failed to enable autostart: {e}"))
}
#[tauri::command]
pub async fn decline_launch_on_login() -> Result<(), String> {
let manager = SettingsManager::instance();
manager
.decline_launch_on_login()
.map_err(|e| format!("Failed to decline launch on login: {e}"))
}
#[tauri::command]
pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String> {
let manager = SettingsManager::instance();
@@ -1047,6 +1013,27 @@ pub async fn get_window_resize_warning_dismissed() -> Result<bool, String> {
Ok(settings.window_resize_warning_dismissed)
}
#[tauri::command]
pub async fn get_onboarding_completed() -> Result<bool, String> {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
Ok(settings.onboarding_completed)
}
#[tauri::command]
pub async fn complete_onboarding() -> Result<(), String> {
let manager = SettingsManager::instance();
let mut settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
settings.onboarding_completed = true;
manager
.save_settings(&settings)
.map_err(|e| format!("Failed to save settings: {e}"))
}
#[tauri::command]
pub fn get_system_language() -> String {
sys_locale::get_locale()
@@ -1182,9 +1169,9 @@ mod tests {
mcp_enabled: false,
mcp_port: None,
mcp_token: None,
launch_on_login_declined: false,
language: None,
window_resize_warning_dismissed: false,
onboarding_completed: false,
disable_auto_updates: false,
keep_decrypted_profiles_in_ram: false,
};
@@ -1247,29 +1234,6 @@ mod tests {
);
}
#[test]
fn test_should_show_launch_on_login_prompt() {
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let result = manager.should_show_launch_on_login_prompt();
assert!(result.is_ok(), "Should not fail");
let _should_show = result.unwrap();
}
#[test]
fn test_decline_launch_on_login() {
let (manager, _temp_dir, _guard) = create_test_settings_manager();
let settings = manager.load_settings().unwrap();
assert!(!settings.launch_on_login_declined);
manager.decline_launch_on_login().unwrap();
let settings = manager.load_settings().unwrap();
assert!(settings.launch_on_login_declined);
}
#[test]
fn test_load_corrupted_settings_file() {
let (manager, _temp_dir, _guard) = create_test_settings_manager();
+37
View File
@@ -49,6 +49,21 @@ impl SyncClient {
&self,
key: &str,
content_type: Option<&str>,
) -> SyncResult<PresignUploadResponse> {
self
.presign_upload_with_metadata(key, content_type, None)
.await
}
/// Presign an upload, asking the server to sign `metadata` into the object as
/// `x-amz-meta-*`. The response echoes the metadata the server actually signed
/// (empty/None on older servers); the caller must send exactly that back on
/// the PUT via `upload_bytes_with_metadata`.
pub async fn presign_upload_with_metadata(
&self,
key: &str,
content_type: Option<&str>,
metadata: Option<std::collections::HashMap<String, String>>,
) -> SyncResult<PresignUploadResponse> {
let response = self
.client
@@ -58,6 +73,7 @@ impl SyncClient {
key: key.to_string(),
content_type: content_type.map(|s| s.to_string()),
expires_in: Some(3600),
metadata,
})
.send()
.await
@@ -186,6 +202,21 @@ impl SyncClient {
presigned_url: &str,
data: &[u8],
content_type: Option<&str>,
) -> SyncResult<()> {
self
.upload_bytes_with_metadata(presigned_url, data, content_type, None)
.await
}
/// PUT to a presigned URL, sending `metadata` as `x-amz-meta-*` headers. These
/// MUST be exactly the metadata the presign signed (from
/// `PresignUploadResponse::metadata`) or S3 rejects the request.
pub async fn upload_bytes_with_metadata(
&self,
presigned_url: &str,
data: &[u8],
content_type: Option<&str>,
metadata: Option<&std::collections::HashMap<String, String>>,
) -> SyncResult<()> {
let mut req = self
.client
@@ -197,6 +228,12 @@ impl SyncClient {
req = req.header("Content-Type", ct);
}
if let Some(meta) = metadata {
for (k, v) in meta {
req = req.header(format!("x-amz-meta-{k}"), v);
}
}
let response = req
.send()
.await
+96 -101
View File
@@ -15,6 +15,11 @@ use std::sync::{Arc, Mutex as StdMutex};
use std::time::Instant;
use tokio::sync::{Mutex as TokioMutex, Semaphore};
/// S3 object-metadata key (stored as `x-amz-meta-updated-at`) holding an
/// entity's user-edit timestamp in unix seconds. Used to resolve sync conflicts
/// (last-write-wins) from a HEAD request without downloading the object body.
const UPDATED_AT_META_KEY: &str = "updated-at";
lazy_static::lazy_static! {
static ref SYNC_CANCEL_FLAGS: StdMutex<HashMap<String, Arc<AtomicBool>>> =
StdMutex::new(HashMap::new());
@@ -358,6 +363,67 @@ impl SyncEngine {
!crate::cloud_auth::CLOUD_AUTH.is_logged_in().await
}
/// Resolve a remote config object's user-edit timestamp (`updated_at`) for
/// conflict resolution. Prefers the value from S3 object metadata returned by
/// the HEAD (`stat`) — no body transfer. Falls back to downloading and
/// decrypting the small JSON body and reading its embedded `updated_at` (for
/// older self-hosted servers that don't surface metadata). Legacy objects with
/// neither resolve to 0, so any real local edit (`updated_at` > 0) wins.
async fn remote_updated_at(&self, stat: &StatResponse, remote_key: &str) -> u64 {
if let Some(meta) = &stat.metadata {
if let Some(v) = meta
.get(UPDATED_AT_META_KEY)
.and_then(|s| s.parse::<u64>().ok())
{
return v;
}
}
// Fallback: read updated_at from the (small) JSON body.
if let Ok(presign) = self.client.presign_download(remote_key).await {
if let Ok(raw) = self.client.download_bytes(&presign.url).await {
if let Ok(data) = encryption::maybe_unseal_after_download(&raw) {
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&data) {
if let Some(u) = val.get("updated_at").and_then(|x| x.as_u64()) {
return u;
}
}
}
}
}
0
}
/// Upload a small config JSON blob (proxy/vpn/group/extension/extension-group/
/// profile metadata), signing its `updated_at` into S3 object metadata so
/// future reconciles can compare via HEAD without downloading the body. The
/// body is sealed (E2E) exactly as before; only a plaintext unix timestamp
/// lives in the object metadata.
async fn upload_config_json(
&self,
remote_key: &str,
json: &str,
updated_at: u64,
) -> SyncResult<()> {
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal config: {e}")))?;
let mut meta = HashMap::new();
meta.insert(UPDATED_AT_META_KEY.to_string(), updated_at.to_string());
let presign = self
.client
.presign_upload_with_metadata(remote_key, Some(content_type), Some(meta))
.await?;
self
.client
.upload_bytes_with_metadata(
&presign.url,
&payload,
Some(content_type),
presign.metadata.as_ref(),
)
.await?;
Ok(())
}
pub async fn sync_profile(
&self,
app_handle: &tauri::AppHandle,
@@ -1431,21 +1497,13 @@ impl SyncEngine {
match (local_proxy, stat.exists) {
(Some(proxy), true) => {
// Both exist - compare timestamps
let local_updated = proxy.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = proxy.updated_at.unwrap_or(0);
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
// Remote is newer - download
if remote_updated > local_updated {
self.download_proxy(proxy_id, app_handle).await?;
} else if local_updated > remote_ts {
// Local is newer - upload
} else if local_updated > remote_updated {
self.upload_proxy(&proxy).await?;
}
}
@@ -1478,17 +1536,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_proxy)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize proxy: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal proxy: {e}")))?;
let remote_key = format!("proxies/{}.json", proxy.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, updated_proxy.updated_at.unwrap_or(0))
.await?;
// Update local proxy with new last_sync (always write plaintext locally)
@@ -1579,21 +1629,13 @@ impl SyncEngine {
match (local_group, stat.exists) {
(Some(group), true) => {
// Both exist - compare timestamps
let local_updated = group.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = group.updated_at.unwrap_or(0);
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
// Remote is newer - download
if remote_updated > local_updated {
self.download_group(group_id, app_handle).await?;
} else if local_updated > remote_ts {
// Local is newer - upload
} else if local_updated > remote_updated {
self.upload_group(&group).await?;
}
}
@@ -1626,17 +1668,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_group)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize group: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal group: {e}")))?;
let remote_key = format!("groups/{}.json", group.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, updated_group.updated_at.unwrap_or(0))
.await?;
// Update local group with new last_sync
@@ -1795,18 +1829,13 @@ impl SyncEngine {
match (local_vpn, stat.exists) {
(Some(vpn), true) => {
let local_updated = vpn.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = vpn.updated_at.unwrap_or(0);
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
if remote_updated > local_updated {
self.download_vpn(vpn_id, app_handle).await?;
} else if local_updated > remote_ts {
} else if local_updated > remote_updated {
self.upload_vpn(&vpn).await?;
}
}
@@ -1836,17 +1865,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_vpn)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize VPN: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal VPN: {e}")))?;
let remote_key = format!("vpns/{}.json", vpn.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, updated_vpn.updated_at.unwrap_or(0))
.await?;
// Update local VPN with new last_sync
@@ -1946,18 +1967,13 @@ impl SyncEngine {
match (local_ext, stat.exists) {
(Some(ext), true) => {
let local_updated = ext.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = ext.updated_at;
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
if remote_updated > local_updated {
self.download_extension(ext_id, app_handle).await?;
} else if local_updated > remote_ts {
} else if local_updated > remote_updated {
self.upload_extension(&ext).await?;
}
}
@@ -1987,17 +2003,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&updated_ext)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize extension: {e}")))?;
let (meta_payload, meta_content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension: {e}")))?;
let remote_key = format!("extensions/{}.json", ext.id);
let presign = self
.client
.presign_upload(&remote_key, Some(meta_content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &meta_payload, Some(meta_content_type))
.upload_config_json(&remote_key, &json, updated_ext.updated_at)
.await?;
// Also upload the extension file data — encrypted as a sealed envelope
@@ -2151,18 +2159,13 @@ impl SyncEngine {
match (local_group, stat.exists) {
(Some(group), true) => {
let local_updated = group.last_sync.unwrap_or(0);
let remote_updated: DateTime<Utc> = stat
.last_modified
.as_ref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(Utc::now);
let remote_ts = remote_updated.timestamp() as u64;
// Both exist - resolve by user-edit timestamp (last-write-wins).
let local_updated = group.updated_at;
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if remote_ts > local_updated {
if remote_updated > local_updated {
self.download_extension_group(group_id, app_handle).await?;
} else if local_updated > remote_ts {
} else if local_updated > remote_updated {
self.upload_extension_group(&group).await?;
}
}
@@ -2196,17 +2199,9 @@ impl SyncEngine {
SyncError::SerializationError(format!("Failed to serialize extension group: {e}"))
})?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension group: {e}")))?;
let remote_key = format!("extension_groups/{}.json", group.id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, updated_group.updated_at)
.await?;
// Update local group with new last_sync
+14
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatRequest {
@@ -11,6 +12,11 @@ pub struct StatResponse {
#[serde(rename = "lastModified")]
pub last_modified: Option<String>,
pub size: Option<u64>,
/// User-defined S3 object metadata (`x-amz-meta-*`), lowercased keys without
/// the prefix. `None` from older servers that don't return it. Used to read
/// `updated-at` for sync conflict resolution without downloading the body.
#[serde(default)]
pub metadata: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -20,6 +26,9 @@ pub struct PresignUploadRequest {
pub content_type: Option<String>,
#[serde(rename = "expiresIn")]
pub expires_in: Option<u64>,
/// Object metadata to sign into the presigned PUT (stored as `x-amz-meta-*`).
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -27,6 +36,11 @@ pub struct PresignUploadResponse {
pub url: String,
#[serde(rename = "expiresAt")]
pub expires_at: String,
/// The metadata the server actually signed into the URL. The client must send
/// exactly these as `x-amz-meta-*` headers on the PUT or S3 rejects it. `None`
/// from older servers → client sends no metadata headers (body-GET fallback).
#[serde(default)]
pub metadata: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+4
View File
@@ -52,6 +52,10 @@ pub struct VpnConfig {
pub sync_enabled: bool,
#[serde(default)]
pub last_sync: Option<u64>,
/// Unix seconds of the last meaningful user edit. Source of truth for sync
/// conflict resolution (last-write-wins); bumped on config edits only.
#[serde(default)]
pub updated_at: Option<u64>,
}
/// Parsed WireGuard configuration
+12
View File
@@ -36,6 +36,8 @@ struct StoredVpnConfig {
sync_enabled: bool,
#[serde(default)]
last_sync: Option<u64>,
#[serde(default)]
updated_at: Option<u64>,
}
/// VPN storage manager with encryption
@@ -247,6 +249,7 @@ impl VpnStorage {
last_used: config.last_used,
sync_enabled: config.sync_enabled,
last_sync: config.last_sync,
updated_at: config.updated_at,
};
// Update existing or add new
@@ -280,6 +283,7 @@ impl VpnStorage {
last_used: stored.last_used,
sync_enabled: stored.sync_enabled,
last_sync: stored.last_sync,
updated_at: stored.updated_at,
})
}
@@ -300,6 +304,7 @@ impl VpnStorage {
last_used: stored.last_used,
sync_enabled: stored.sync_enabled,
last_sync: stored.last_sync,
updated_at: stored.updated_at,
})
.collect(),
)
@@ -356,6 +361,7 @@ impl VpnStorage {
last_used: None,
sync_enabled,
last_sync: None,
updated_at: Some(crate::proxy_manager::now_secs()),
};
self.save_config(&config)?;
@@ -367,6 +373,7 @@ impl VpnStorage {
pub fn update_config_name(&self, id: &str, new_name: &str) -> Result<VpnConfig, VpnError> {
let mut config = self.load_config(id)?;
config.name = new_name.to_string();
config.updated_at = Some(crate::proxy_manager::now_secs());
self.save_config(&config)?;
Ok(config)
}
@@ -420,6 +427,7 @@ impl VpnStorage {
last_used: None,
sync_enabled,
last_sync: None,
updated_at: Some(crate::proxy_manager::now_secs()),
};
self.save_config(&config)?;
@@ -463,6 +471,7 @@ mod tests {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config).unwrap();
@@ -487,6 +496,7 @@ mod tests {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
let config2 = VpnConfig {
@@ -498,6 +508,7 @@ mod tests {
last_used: Some(3000),
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config1).unwrap();
@@ -524,6 +535,7 @@ mod tests {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config).unwrap();
+34 -4
View File
@@ -51,6 +51,12 @@ pub struct WayfernLaunchResult {
pub profilePath: Option<String>,
pub url: Option<String>,
pub cdp_port: Option<u16>,
/// The fingerprint Wayfern actually applied, echoed back by
/// Wayfern.setFingerprint. It may be UPGRADED from the stored fingerprint
/// (e.g. when the stored one targets an older browser version). Internal
/// only — the caller persists it to the profile; never sent to the frontend.
#[serde(default, skip_serializing)]
pub used_fingerprint: Option<String>,
}
struct WayfernInstance {
@@ -703,6 +709,7 @@ impl WayfernManager {
log::info!("Found {} page targets", page_targets.len());
// Apply fingerprint if configured
let mut used_fingerprint: Option<String> = None;
if let Some(fingerprint_json) = &config.fingerprint {
log::info!(
"Applying fingerprint to Wayfern browser, fingerprint length: {} chars",
@@ -781,10 +788,30 @@ impl WayfernManager {
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone())
.await
{
Ok(result) => log::info!(
"Successfully applied fingerprint to page target: {:?}",
result
),
Ok(result) => {
log::info!(
"Successfully applied fingerprint to page target: {:?}",
result
);
// Wayfern.setFingerprint echoes back the fingerprint it actually
// used, which may be UPGRADED from what we sent (e.g. when the
// stored fingerprint targets an older browser version). Capture
// it once, from the first target that succeeds, so the caller can
// persist the upgraded value to the profile.
if used_fingerprint.is_none() {
// getFingerprint/setFingerprint wrap the object as
// { fingerprint: {...} }; tolerate a bare object too.
let fp = result.get("fingerprint").cloned().unwrap_or(result);
if fp.is_object() {
match serde_json::to_string(&Self::normalize_fingerprint(fp)) {
Ok(s) => used_fingerprint = Some(s),
Err(e) => {
log::warn!("Failed to serialize used fingerprint: {e}")
}
}
}
}
}
Err(e) => log::error!("Failed to apply fingerprint to target: {e}"),
}
}
@@ -849,6 +876,7 @@ impl WayfernManager {
profilePath: Some(profile_path.to_string()),
url: url.map(|s| s.to_string()),
cdp_port: Some(port),
used_fingerprint,
})
}
@@ -990,6 +1018,7 @@ impl WayfernManager {
profilePath: instance.profile_path.clone(),
url: instance.url.clone(),
cdp_port: instance.cdp_port,
used_fingerprint: None,
});
} else {
log::info!(
@@ -1032,6 +1061,7 @@ impl WayfernManager {
profilePath: Some(found_profile_path),
url: None,
cdp_port,
used_fingerprint: None,
});
}
+4 -4
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.24.4",
"version": "0.25.0",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
@@ -19,7 +19,7 @@
"active": true,
"targets": ["app", "dmg", "nsis", "deb", "rpm", "appimage"],
"category": "Productivity",
"externalBin": ["binaries/donut-proxy", "binaries/donut-daemon"],
"externalBin": ["binaries/donut-proxy"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -42,11 +42,11 @@
"linux": {
"deb": {
"desktopTemplate": "donutbrowser.desktop",
"depends": ["xdg-utils", "libxdo3"]
"depends": ["xdg-utils", "libxdo3", "libayatana-appindicator3-1"]
},
"rpm": {
"desktopTemplate": "donutbrowser.desktop",
"depends": ["xdg-utils", "libxdo"]
"depends": ["xdg-utils", "libxdo", "libayatana-appindicator-gtk3"]
},
"appimage": {
"files": {
+4
View File
@@ -135,6 +135,7 @@ fn test_vpn_storage_save_and_load() {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
let save_result = storage.save_config(&config);
@@ -174,6 +175,7 @@ fn test_vpn_storage_list() {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config).unwrap();
}
@@ -201,6 +203,7 @@ fn test_vpn_storage_delete() {
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
};
storage.save_config(&config).unwrap();
@@ -489,6 +492,7 @@ fn new_test_vpn_config(name: &str, vpn_type: VpnType, config_data: String) -> Vp
last_used: None,
sync_enabled: false,
last_sync: None,
updated_at: None,
}
}
+122 -41
View File
@@ -3,6 +3,7 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/plugin-deep-link";
import { useOnborda } from "onborda";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AccountPage } from "@/components/account-page";
@@ -23,7 +24,7 @@ import { GroupManagementDialog } from "@/components/group-management-dialog";
import HomeHeader from "@/components/home-header";
import { ImportProfileDialog } from "@/components/import-profile-dialog";
import { IntegrationsDialog } from "@/components/integrations-dialog";
import { LaunchOnLoginDialog } from "@/components/launch-on-login-dialog";
import { ONBOARDING_TOUR } from "@/components/onboarding-provider";
import { PermissionDialog } from "@/components/permission-dialog";
import { ProfilesDataTable } from "@/components/profile-data-table";
import {
@@ -40,7 +41,9 @@ import { ShortcutsPage } from "@/components/shortcuts-page";
import { SyncAllDialog } from "@/components/sync-all-dialog";
import { SyncConfigDialog } from "@/components/sync-config-dialog";
import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
import { ThankYouDialog } from "@/components/thank-you-dialog";
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
import { WelcomeDialog } from "@/components/welcome-dialog";
import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog";
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
@@ -56,6 +59,10 @@ import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors";
import {
ONBOARDING_TOUR_FINISHED_EVENT,
setOnboardingActive,
} from "@/lib/onboarding-signal";
import {
matchesGroupDigit,
matchesShortcut,
@@ -96,6 +103,95 @@ export default function Home() {
error: profilesError,
} = useProfileEvents();
// First-run onboarding tour (Onborda).
const { startOnborda, setCurrentStep, isOnbordaVisible, currentStep } =
useOnborda();
const onboardingHandledRef = useRef(false);
const [welcomeOpen, setWelcomeOpen] = useState(false);
const [thankYouOpen, setThankYouOpen] = useState(false);
// null = onboarding decision pending; false = not a first-run onboarding (run
// the normal permission checks); true = first-run onboarding, so the welcome
// flow drives permissions and the standalone permission dialog is suppressed.
const [firstRunOnboarding, setFirstRunOnboarding] = useState<boolean | null>(
null,
);
// Welcome flow finished. Existing-profile users are done after the welcome +
// commercial-use steps; users with no profile yet continue into the in-app
// product tour that walks them through creating their first profile.
const handleWelcomeComplete = useCallback(() => {
setWelcomeOpen(false);
setFirstRunOnboarding(false);
if (profiles.length === 0) {
startOnborda(ONBOARDING_TOUR);
}
}, [startOnborda, profiles.length]);
// The product tour finished (user clicked "Finish", not "Skip") → celebrate.
useEffect(() => {
const handler = () => setThankYouOpen(true);
window.addEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handler);
return () =>
window.removeEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handler);
}, []);
// Suppress the global browser-download toasts while onboarding (welcome or
// tour) is active — the welcome dialog shows setup progress itself.
useEffect(() => {
setOnboardingActive(welcomeOpen || isOnbordaVisible);
}, [welcomeOpen, isOnbordaVisible]);
// While the tour is visible, keep the body pinned to the left. Onborda calls
// scrollIntoView({ inline: "center" }) on the highlighted element; because the
// body is overflow-hidden it can still be scrolled programmatically, which
// would shove the whole app (rail and all) sideways with no way to scroll
// back. The profile table keeps its own scroll container, untouched here.
useEffect(() => {
if (!isOnbordaVisible) return;
const pin = () => {
if (document.body.scrollLeft !== 0) document.body.scrollLeft = 0;
if (document.documentElement.scrollLeft !== 0)
document.documentElement.scrollLeft = 0;
};
pin();
window.addEventListener("scroll", pin, true);
return () => window.removeEventListener("scroll", pin, true);
}, [isOnbordaVisible]);
// On the very first launch, always show the welcome + commercial-use steps
// (one-shot: the backend flag is set immediately so it can't trigger again).
// The welcome dialog itself decides whether to continue into the browser
// download + profile-creation flow — only when the user has no profile yet.
useEffect(() => {
if (profilesLoading || onboardingHandledRef.current) return;
onboardingHandledRef.current = true;
void (async () => {
try {
const completed = await invoke<boolean>("get_onboarding_completed");
if (completed) {
setFirstRunOnboarding(false);
return;
}
await invoke("complete_onboarding");
setFirstRunOnboarding(true);
setWelcomeOpen(true);
} catch (err) {
console.error("Onboarding init failed:", err);
setFirstRunOnboarding(false);
}
})();
}, [profilesLoading]);
// Advance from the "create a profile" step to the "DNS blocking" step as soon
// as the user's first profile exists (its DNS dropdown is now in the DOM).
useEffect(() => {
if (isOnbordaVisible && currentStep === 0 && profiles.length > 0) {
// Small delay so the new profile row (and its DNS dropdown target) has
// mounted before Onborda re-points at it.
setCurrentStep(1, 300);
}
}, [isOnbordaVisible, currentStep, profiles.length, setCurrentStep]);
const {
groups: groupsData,
isLoading: groupsLoading,
@@ -215,8 +311,6 @@ export default function Home() {
const [passwordDialogMode, setPasswordDialogMode] =
useState<PasswordDialogMode>("set");
const pendingLaunchAfterUnlockRef = useRef<BrowserProfile | null>(null);
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false);
const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false);
const [windowResizeWarningBrowserType, setWindowResizeWarningBrowserType] =
useState<string | undefined>(undefined);
@@ -546,24 +640,6 @@ export default function Home() {
}
}, [handleUrlOpen, hasCheckedStartupUrl]);
const checkStartupPrompt = useCallback(async () => {
// Only check once during app startup to prevent reopening after dismissing notifications
if (hasCheckedStartupPrompt) return;
try {
const shouldShow = await invoke<boolean>(
"should_show_launch_on_login_prompt",
);
if (shouldShow) {
setLaunchOnLoginDialogOpen(true);
}
} catch (error) {
console.error("Failed to check startup prompt:", error);
} finally {
setHasCheckedStartupPrompt(true);
}
}, [hasCheckedStartupPrompt]);
// Handle profile errors from useProfileEvents hook
useEffect(() => {
if (profilesError) {
@@ -796,9 +872,12 @@ export default function Home() {
} catch (error) {
showErrorToast(
t("errors.createProfileFailed", {
error: error instanceof Error ? error.message : String(error),
error: translateBackendError(t, error),
}),
);
// Rethrow so the create dialog keeps itself open (its own handler
// skips closing on error), letting the user fix the proxy/VPN and retry.
throw error;
}
},
[selectedGroupId, t],
@@ -1190,9 +1269,6 @@ export default function Home() {
}, [profiles, t]);
useEffect(() => {
// Check for startup default browser prompt
void checkStartupPrompt();
// Listen for URL open events and get cleanup function
const setupListeners = async () => {
const cleanup = await listenForUrlEvents();
@@ -1235,7 +1311,6 @@ export default function Home() {
};
}, [
checkForUpdates,
checkStartupPrompt,
listenForUrlEvents,
checkCurrentUrl,
checkMissingBinaries,
@@ -1337,11 +1412,13 @@ export default function Home() {
showToast({
id: "browser-support-ending-warning",
type: "error",
title: "Browser support ending soon",
description: `Support for the following profiles will be removed on March 15, 2026: ${unsupportedNames}. Please migrate to Wayfern or Camoufox profiles.`,
title: t("browserSupport.endingSoonTitle"),
description: t("browserSupport.endingSoonDescription", {
profiles: unsupportedNames,
}),
duration: 15000,
action: {
label: "Learn more",
label: t("common.buttons.learnMore"),
onClick: () => {
const event = new CustomEvent("url-open-request", {
detail: "https://github.com/zhom/donutbrowser/discussions",
@@ -1351,7 +1428,7 @@ export default function Home() {
},
});
}
}, [profiles]);
}, [profiles, t]);
// Re-check Wayfern terms when a browser download completes
useEffect(() => {
@@ -1372,12 +1449,14 @@ export default function Home() {
};
}, [checkTerms]);
// Check permissions when they are initialized
// Check permissions when they are initialized. During first-run onboarding
// the welcome flow requests permissions, so the standalone dialog is deferred
// until we know this isn't a first-run onboarding.
useEffect(() => {
if (isInitialized) {
if (isInitialized && firstRunOnboarding === false) {
checkAllPermissions();
}
}, [isInitialized, checkAllPermissions]);
}, [isInitialized, firstRunOnboarding, checkAllPermissions]);
// Check self-hosted sync config on mount and when cloud user changes
useEffect(() => {
@@ -1647,6 +1726,16 @@ export default function Home() {
onPermissionGranted={checkNextPermission}
/>
<WelcomeDialog
isOpen={welcomeOpen}
needsSetup={profiles.length === 0}
onComplete={handleWelcomeComplete}
/>
<ThankYouDialog
isOpen={thankYouOpen}
onClose={() => setThankYouOpen(false)}
/>
<CloneProfileDialog
isOpen={!!cloneProfile}
onClose={() => {
@@ -1851,14 +1940,6 @@ export default function Home() {
onClose={checkTrialStatus}
/>
{/* Launch on Login Dialog - shown on every startup until enabled or declined */}
<LaunchOnLoginDialog
isOpen={launchOnLoginDialogOpen}
onClose={() => {
setLaunchOnLoginDialogOpen(false);
}}
/>
<WindowResizeWarningDialog
isOpen={windowResizeWarningOpen}
browserType={windowResizeWarningBrowserType}
+31
View File
@@ -280,9 +280,40 @@ export function AccountPage({
<p className="mt-0.5">{user.planPeriod}</p>
</div>
)}
{typeof user.deviceOrdinal === "number" && (
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("account.fields.device")}
</p>
<p className="mt-0.5">
{t("account.deviceOrdinal", {
ordinal: user.deviceOrdinal,
count: user.deviceCount ?? user.deviceOrdinal,
})}
</p>
</div>
)}
</div>
)}
{isLoggedIn &&
user &&
user.plan !== "free" &&
user.isPrimaryDevice === false && (
<p className="text-xs text-warning">
{t("account.automationPrimaryOnly")}
</p>
)}
{isLoggedIn &&
user &&
user.plan !== "free" &&
user.isPrimaryDevice === true &&
(user.deviceCount ?? 1) > 1 && (
<p className="text-xs text-success">
{t("account.automationActiveHere")}
</p>
)}
<div className="flex flex-wrap gap-2 mt-2">
{isLoggedIn ? (
<>
+1 -1
View File
@@ -37,7 +37,7 @@ export function AppUpdateToast({
return (
<div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
<div className="mr-3 mt-0.5">
<LuCheckCheck className="flex-shrink-0 size-5" />
<LuCheckCheck className="shrink-0 size-5" />
</div>
<div className="flex-1 min-w-0">
+4 -1
View File
@@ -2,6 +2,7 @@
import { useEffect } from "react";
import { I18nProvider } from "@/components/i18n-provider";
import { OnboardingProvider } from "@/components/onboarding-provider";
import { CustomThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -17,7 +18,9 @@ export function ClientProviders({ children }: { children: React.ReactNode }) {
<I18nProvider>
<CustomThemeProvider>
<WindowDragArea />
<TooltipProvider>{children}</TooltipProvider>
<TooltipProvider>
<OnboardingProvider>{children}</OnboardingProvider>
</TooltipProvider>
<Toaster />
</CustomThemeProvider>
</I18nProvider>
+19 -1
View File
@@ -15,7 +15,7 @@ import {
import { RippleButton } from "./ui/ripple";
export function CloseConfirmDialog() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
@@ -29,6 +29,24 @@ export function CloseConfirmDialog() {
};
}, []);
// The native tray menu is built in Rust and cannot read the active language,
// so push localized labels to it on mount and whenever the language changes.
useEffect(() => {
const syncTrayMenu = () => {
void invoke("update_tray_menu", {
showLabel: t("tray.show"),
quitLabel: t("tray.quit"),
}).catch(() => {
// Tray is desktop-only; ignore on platforms without one.
});
};
syncTrayMenu();
i18n.on("languageChanged", syncTrayMenu);
return () => {
i18n.off("languageChanged", syncTrayMenu);
};
}, [t, i18n]);
const handleMinimize = async () => {
setIsOpen(false);
try {
+137 -41
View File
@@ -11,7 +11,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
import { LuCheck, LuChevronsUpDown, LuLoaderCircle } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
@@ -307,6 +307,10 @@ export function CreateProfileDialog({
useEffect(() => {
if (isOpen) {
void loadSupportedBrowsers();
// Load downloaded versions for both anti-detect browsers up front so the
// selection-screen availability gate is accurate before either is picked.
void loadDownloadedVersions("wayfern");
void loadDownloadedVersions("camoufox");
// Load release types when a browser is selected
if (selectedBrowser) {
void loadReleaseTypes(selectedBrowser);
@@ -320,6 +324,7 @@ export function CreateProfileDialog({
isOpen,
loadSupportedBrowsers,
loadReleaseTypes,
loadDownloadedVersions,
checkAndDownloadGeoIPDatabase,
selectedBrowser,
]);
@@ -405,6 +410,7 @@ export function CreateProfileDialog({
const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId;
const resolvedVpnId =
isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined;
const passwordToSet =
enablePassword && !ephemeral && password.length >= PASSWORD_MIN_LEN
? password
@@ -585,7 +591,7 @@ export function CreateProfileDialog({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="w-[380px] max-w-[380px] max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogHeader className="shrink-0">
<DialogTitle>
{currentStep === "browser-selection"
? t("createProfile.title")
@@ -618,23 +624,30 @@ export function CreateProfileDialog({
onClick={() => {
handleBrowserSelect("wayfern");
}}
disabled={!getCreatableVersion("wayfern")}
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center size-8">
{(() => {
const IconComponent = getBrowserIcon("wayfern");
return IconComponent ? (
<IconComponent className="size-6" />
) : null;
})()}
{isBrowserCurrentlyDownloading("wayfern") ? (
<LuLoaderCircle className="size-6 animate-spin" />
) : (
(() => {
const IconComponent = getBrowserIcon("wayfern");
return IconComponent ? (
<IconComponent className="size-6" />
) : null;
})()
)}
</div>
<div className="text-left">
<div className="font-medium">
{t("createProfile.chromiumLabel")}
</div>
<div className="text-sm text-muted-foreground">
{t("createProfile.chromiumSubtitle")}
{isBrowserCurrentlyDownloading("wayfern")
? t("createProfile.downloadingSubtitle")
: t("createProfile.chromiumSubtitle")}
</div>
</div>
</Button>
@@ -644,26 +657,41 @@ export function CreateProfileDialog({
onClick={() => {
handleBrowserSelect("camoufox");
}}
disabled={!getCreatableVersion("camoufox")}
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center size-8">
{(() => {
const IconComponent = getBrowserIcon("camoufox");
return IconComponent ? (
<IconComponent className="size-6" />
) : null;
})()}
{isBrowserCurrentlyDownloading("camoufox") ? (
<LuLoaderCircle className="size-6 animate-spin" />
) : (
(() => {
const IconComponent =
getBrowserIcon("camoufox");
return IconComponent ? (
<IconComponent className="size-6" />
) : null;
})()
)}
</div>
<div className="text-left">
<div className="font-medium">
{t("createProfile.firefoxLabel")}
</div>
<div className="text-sm text-muted-foreground">
{t("createProfile.firefoxSubtitle")}
{isBrowserCurrentlyDownloading("camoufox")
? t("createProfile.downloadingSubtitle")
: t("createProfile.firefoxSubtitle")}
</div>
</div>
</Button>
{!getCreatableVersion("wayfern") &&
!getCreatableVersion("camoufox") && (
<p className="pt-2 text-sm text-center text-muted-foreground">
{t("createProfile.browsersDownloading")}
</p>
)}
</div>
</TabsContent>
@@ -867,7 +895,7 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("wayfern") &&
!isBrowserVersionAvailable("wayfern") &&
!getCreatableVersion("wayfern") &&
getBestAvailableVersion("wayfern") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="text-sm text-muted-foreground">
@@ -899,17 +927,53 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("wayfern") &&
isBrowserVersionAvailable("wayfern") && (
getCreatableVersion("wayfern") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{" "}
{t("createProfile.version.available", {
browser: "Wayfern",
version:
getBestAvailableVersion("wayfern")
?.version,
getCreatableVersion("wayfern")?.version,
})}
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("wayfern") &&
getCreatableVersion("wayfern") &&
!isBrowserVersionAvailable("wayfern") &&
getBestAvailableVersion("wayfern") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="flex-1 text-sm text-muted-foreground">
{t(
"createProfile.version.upgradeAvailable",
{
browser: "Wayfern",
version:
getBestAvailableVersion("wayfern")
?.version,
},
)}
</p>
<LoadingButton
onClick={() => {
void handleDownload("wayfern");
}}
isLoading={isBrowserCurrentlyDownloading(
"wayfern",
)}
size="sm"
variant="outline"
disabled={isBrowserCurrentlyDownloading(
"wayfern",
)}
>
{isBrowserCurrentlyDownloading("wayfern")
? t("common.buttons.downloading")
: t("common.buttons.download")}
</LoadingButton>
</div>
)}
{isBrowserCurrentlyDownloading("wayfern") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{t("createProfile.version.downloading", {
@@ -927,7 +991,7 @@ export function CreateProfileDialog({
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
profileVersion={
getBestAvailableVersion("wayfern")?.version
getCreatableVersion("wayfern")?.version
}
profileBrowser="wayfern"
/>
@@ -975,7 +1039,7 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("camoufox") &&
!isBrowserVersionAvailable("camoufox") &&
!getCreatableVersion("camoufox") &&
getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="text-sm text-muted-foreground">
@@ -1007,17 +1071,53 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("camoufox") &&
isBrowserVersionAvailable("camoufox") && (
getCreatableVersion("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{" "}
{t("createProfile.version.available", {
browser: "Camoufox",
version:
getBestAvailableVersion("camoufox")
?.version,
getCreatableVersion("camoufox")?.version,
})}
</div>
)}
{!isLoadingReleaseTypes &&
!releaseTypesError &&
!isBrowserCurrentlyDownloading("camoufox") &&
getCreatableVersion("camoufox") &&
!isBrowserVersionAvailable("camoufox") &&
getBestAvailableVersion("camoufox") && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<p className="flex-1 text-sm text-muted-foreground">
{t(
"createProfile.version.upgradeAvailable",
{
browser: "Camoufox",
version:
getBestAvailableVersion("camoufox")
?.version,
},
)}
</p>
<LoadingButton
onClick={() => {
void handleDownload("camoufox");
}}
isLoading={isBrowserCurrentlyDownloading(
"camoufox",
)}
size="sm"
variant="outline"
disabled={isBrowserCurrentlyDownloading(
"camoufox",
)}
>
{isBrowserCurrentlyDownloading("camoufox")
? t("common.buttons.downloading")
: t("common.buttons.download")}
</LoadingButton>
</div>
)}
{isBrowserCurrentlyDownloading("camoufox") && (
<div className="p-3 text-sm rounded-md border text-muted-foreground">
{t("createProfile.version.downloading", {
@@ -1045,7 +1145,7 @@ export function CreateProfileDialog({
crossOsUnlocked={crossOsUnlocked}
limitedMode={!crossOsUnlocked}
profileVersion={
getBestAvailableVersion("camoufox")?.version
getCreatableVersion("camoufox")?.version
}
profileBrowser="camoufox"
/>
@@ -1077,7 +1177,7 @@ export function CreateProfileDialog({
size="sm"
variant="outline"
>
Retry
{t("common.buttons.retry")}
</RippleButton>
</div>
)}
@@ -1086,7 +1186,7 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
!isBrowserVersionAvailable(selectedBrowser) &&
!getCreatableVersion(selectedBrowser) &&
getBestAvailableVersion(selectedBrowser) && (
<div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground">
@@ -1122,18 +1222,15 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
isBrowserVersionAvailable(
selectedBrowser,
) && (
getCreatableVersion(selectedBrowser) && (
<div className="text-sm text-muted-foreground">
{" "}
{t(
"createProfile.version.latestAvailable",
{
version:
getBestAvailableVersion(
selectedBrowser,
)?.version,
getCreatableVersion(selectedBrowser)
?.version,
},
)}
</div>
@@ -1432,7 +1529,7 @@ export function CreateProfileDialog({
<div className="flex gap-3 items-center">
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
Fetching available versions...
{t("createProfile.version.fetching")}
</p>
</div>
)}
@@ -1458,7 +1555,7 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
!isBrowserVersionAvailable(selectedBrowser) &&
!getCreatableVersion(selectedBrowser) &&
getBestAvailableVersion(selectedBrowser) && (
<div className="flex gap-3 items-center">
<p className="text-sm text-muted-foreground">
@@ -1494,16 +1591,15 @@ export function CreateProfileDialog({
!isBrowserCurrentlyDownloading(
selectedBrowser,
) &&
isBrowserVersionAvailable(selectedBrowser) && (
getCreatableVersion(selectedBrowser) && (
<div className="text-sm text-muted-foreground">
{" "}
{t(
"createProfile.version.latestAvailable",
{
version:
getBestAvailableVersion(
selectedBrowser,
)?.version,
getCreatableVersion(selectedBrowser)
?.version,
},
)}
</div>
@@ -1701,7 +1797,7 @@ export function CreateProfileDialog({
</ScrollArea>
</Tabs>
<DialogFooter className="flex-shrink-0 pt-4 border-t">
<DialogFooter className="shrink-0 pt-4 border-t">
{currentStep === "browser-config" ? (
<>
<RippleButton variant="outline" onClick={handleBack}>
+11 -15
View File
@@ -174,42 +174,38 @@ function formatEtaCompact(seconds: number): string {
function getToastIcon(type: ToastProps["type"], stage?: string) {
switch (type) {
case "success":
return <LuCheckCheck className="flex-shrink-0 size-4 text-foreground" />;
return <LuCheckCheck className="shrink-0 size-4 text-foreground" />;
case "error":
return (
<LuTriangleAlert className="flex-shrink-0 size-4 text-foreground" />
);
return <LuTriangleAlert className="shrink-0 size-4 text-foreground" />;
case "download":
if (stage === "completed") {
return (
<LuCheckCheck className="flex-shrink-0 size-4 text-foreground" />
);
return <LuCheckCheck className="shrink-0 size-4 text-foreground" />;
}
return <LuDownload className="flex-shrink-0 size-4 text-foreground" />;
return <LuDownload className="shrink-0 size-4 text-foreground" />;
case "version-update":
return (
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "fetching":
return (
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "twilight-update":
return (
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "sync-progress":
return (
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
);
case "loading":
return (
<div className="flex-shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
<div className="shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
);
default:
return (
<div className="flex-shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
<div className="shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
);
}
}
@@ -232,7 +228,7 @@ export function UnifiedToast(props: ToastProps) {
<button
type="button"
onClick={onCancel}
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
aria-label={t("common.buttons.cancel")}
>
<LuX className="size-3" />
@@ -1129,10 +1129,10 @@ export function ExtensionManagementDialog({
{limitedMode && (
<>
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-0 flex items-center justify-center z-[3]">
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
<ProBadge />
+3 -3
View File
@@ -148,10 +148,10 @@ export function GroupBadges({
return (
<div className="relative mb-4">
{showLeftFade && (
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-background to-transparent pointer-events-none z-10" />
<div className="absolute left-0 top-0 bottom-0 w-8 bg-linear-to-r from-background to-transparent pointer-events-none z-10" />
)}
{showRightFade && (
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none z-10" />
<div className="absolute right-0 top-0 bottom-0 w-8 bg-linear-to-l from-background to-transparent pointer-events-none z-10" />
)}
<div
ref={scrollContainerRef}
@@ -165,7 +165,7 @@ export function GroupBadges({
<Badge
key={group.id}
variant={selectedGroupId === group.id ? "default" : "secondary"}
className="flex gap-2 items-center px-3 py-1 transition-colors cursor-pointer dark:hover:bg-primary/60 hover:bg-primary/80 flex-shrink-0"
className="flex gap-2 items-center px-3 py-1 transition-colors cursor-pointer dark:hover:bg-primary/60 hover:bg-primary/80 shrink-0"
onClick={(e) => {
if (hasMovedRef.current || clickBlockedRef.current) {
e.preventDefault();
+1
View File
@@ -321,6 +321,7 @@ const HomeHeader = ({
<span className="shrink-0">
<Button
size="sm"
data-onborda="create-profile"
onClick={() => {
onCreateProfileDialogOpen(true);
}}
+2 -2
View File
@@ -303,7 +303,7 @@ export function ImportProfileDialog({
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
{!subPage && (
<DialogHeader className="flex-shrink-0">
<DialogHeader className="shrink-0">
<DialogTitle>{t("importProfile.title")}</DialogTitle>
</DialogHeader>
)}
@@ -604,7 +604,7 @@ export function ImportProfileDialog({
<div
className={cn(
"flex-shrink-0 flex gap-2 items-center justify-end",
"shrink-0 flex gap-2 items-center justify-end",
subPage ? "pt-2 border-t border-border" : undefined,
)}
>
-106
View File
@@ -1,106 +0,0 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@/components/loading-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
interface LaunchOnLoginDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function LaunchOnLoginDialog({
isOpen,
onClose,
}: LaunchOnLoginDialogProps) {
const { t } = useTranslation();
const [isEnabling, setIsEnabling] = useState(false);
const [isDeclining, setIsDeclining] = useState(false);
const handleEnable = useCallback(async () => {
setIsEnabling(true);
try {
await invoke("enable_launch_on_login");
showSuccessToast(t("launchOnLogin.enableSuccess"));
onClose();
} catch (error) {
console.error("Failed to enable launch on login:", error);
showErrorToast(t("launchOnLogin.enableFailed"), {
description:
error instanceof Error ? error.message : t("launchOnLogin.tryAgain"),
});
} finally {
setIsEnabling(false);
}
}, [onClose, t]);
const handleDecline = useCallback(async () => {
setIsDeclining(true);
try {
await invoke("decline_launch_on_login");
onClose();
} catch (error) {
console.error("Failed to decline launch on login:", error);
showErrorToast(t("launchOnLogin.declineFailed"), {
description:
error instanceof Error ? error.message : t("launchOnLogin.tryAgain"),
});
} finally {
setIsDeclining(false);
}
}, [onClose, t]);
return (
<Dialog open={isOpen}>
<DialogContent
className="sm:max-w-sm"
onEscapeKeyDown={(e) => {
e.preventDefault();
}}
onPointerDownOutside={(e) => {
e.preventDefault();
}}
onInteractOutside={(e) => {
e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>{t("launchOnLogin.title")}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t("launchOnLogin.description")}
</p>
<DialogFooter className="flex-row justify-between sm:justify-between">
<Button
variant="ghost"
onClick={handleDecline}
disabled={isEnabling || isDeclining}
>
{isDeclining
? t("launchOnLogin.declining")
: t("launchOnLogin.declineButton")}
</Button>
<LoadingButton
onClick={handleEnable}
isLoading={isEnabling}
disabled={isDeclining}
>
{t("launchOnLogin.enableButton")}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+100
View File
@@ -0,0 +1,100 @@
"use client";
import type { CardComponentProps } from "onborda";
import { useOnborda } from "onborda";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ONBOARDING_TOUR_FINISHED_EVENT } from "@/lib/onboarding-signal";
// Custom Onborda card, themed with the app's CSS variables. Finishing the last
// step emits ONBOARDING_TOUR_FINISHED_EVENT so the page can show the celebratory
// thank-you dialog (skipping early does not emit it).
export function OnboardingCard({
step,
currentStep,
totalSteps,
nextStep,
prevStep,
arrow,
}: CardComponentProps) {
const { t } = useTranslation();
const { closeOnborda } = useOnborda();
const isFirst = currentStep === 0;
const isLast = currentStep === totalSteps - 1;
// This step is completed by clicking the highlighted element (the "New"
// button), not by a "Next" button — advancing manually would jump to a step
// whose target doesn't exist yet and block the button. So hide "Next" here.
const requiresAction = step.selector === '[data-onborda="create-profile"]';
return (
<div className="relative p-4 w-80 max-w-[90vw] rounded-lg border shadow-lg bg-popover text-popover-foreground">
<div className="flex gap-2 items-start justify-between">
<h3 className="text-sm font-semibold leading-tight">{step.title}</h3>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
{currentStep + 1}/{totalSteps}
</span>
</div>
<div className="mt-2 text-xs leading-relaxed text-muted-foreground">
{step.content}
</div>
<div className="flex gap-2 items-center justify-between mt-4">
{isLast ? (
<span />
) : (
<Button
variant="ghost"
size="sm"
className="text-xs h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => {
closeOnborda();
}}
>
{t("onboarding.buttons.skip")}
</Button>
)}
<div className="flex gap-2 items-center">
{!isFirst && !isLast && (
<Button
variant="outline"
size="sm"
className="text-xs h-7 px-2.5"
onClick={() => {
prevStep();
}}
>
{t("onboarding.buttons.back")}
</Button>
)}
{isLast ? (
<Button
size="sm"
className="text-xs h-7 px-3"
onClick={() => {
closeOnborda();
window.dispatchEvent(new Event(ONBOARDING_TOUR_FINISHED_EVENT));
}}
>
{t("onboarding.buttons.finish")}
</Button>
) : requiresAction ? null : (
<Button
size="sm"
className="text-xs h-7 px-3"
onClick={() => {
nextStep();
}}
>
{t("onboarding.buttons.next")}
</Button>
)}
</div>
</div>
<span className="text-popover">{arrow}</span>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { Onborda, type OnbordaProps, OnbordaProvider } from "onborda";
import { useTranslation } from "react-i18next";
import { OnboardingCard } from "@/components/onboarding-card";
// Name of the first-run product tour. Referenced by the trigger logic in
// `src/app/page.tsx` via `startOnborda(ONBOARDING_TOUR)`.
export const ONBOARDING_TOUR = "donut-onboarding";
export function OnboardingProvider({
children,
}: {
children: React.ReactNode;
}) {
const { t } = useTranslation();
const tours: OnbordaProps["steps"] = [
{
tour: ONBOARDING_TOUR,
steps: [
{
icon: null,
title: t("onboarding.steps.createProfile.title"),
content: t("onboarding.steps.createProfile.content"),
selector: '[data-onborda="create-profile"]',
// The "New" button sits in the top-right corner; "bottom-right"
// anchors the card's right edge to it so the card extends left/down
// and stays on-screen instead of overflowing the right viewport edge.
side: "bottom-right",
showControls: true,
pointerPadding: 8,
pointerRadius: 10,
},
{
icon: null,
title: t("onboarding.steps.dnsBlocking.title"),
content: t("onboarding.steps.dnsBlocking.content"),
selector: '[data-onborda="dns-blocklist"]',
// The DNS dropdown sits in the right-hand columns. A centered "bottom"
// card runs off the right edge; "bottom-right" anchors the card's right
// edge to the dropdown and extends it left/down, keeping it fully
// on-screen with its arrow pointing up at the option.
side: "bottom-right",
showControls: true,
pointerPadding: 6,
pointerRadius: 8,
},
],
},
];
return (
<OnbordaProvider>
<Onborda
steps={tours}
cardComponent={OnboardingCard}
interact
shadowRgb="0,0,0"
shadowOpacity="0.6"
>
{children}
</Onborda>
</OnbordaProvider>
);
}
+4 -6
View File
@@ -131,9 +131,9 @@ export function PermissionDialog({
const getPermissionIcon = (type: PermissionType) => {
switch (type) {
case "microphone":
return <BsMic className="size-8" />;
return <BsMic className="size-5 shrink-0" />;
case "camera":
return <BsCamera className="size-8" />;
return <BsCamera className="size-5 shrink-0" />;
}
};
@@ -195,13 +195,11 @@ export function PermissionDialog({
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader className="text-center">
<div className="flex justify-center items-center mx-auto mb-4 size-16 bg-primary/15 rounded-full">
<DialogTitle className="flex items-center justify-center gap-2 text-xl">
{getPermissionIcon(permissionType)}
</div>
<DialogTitle className="text-xl">
{getPermissionTitle(permissionType)}
</DialogTitle>
<DialogDescription className="text-base">
<DialogDescription className="text-base text-pretty">
{getPermissionDescription(permissionType)}
</DialogDescription>
</DialogHeader>
+1
View File
@@ -441,6 +441,7 @@ function DnsCell({
<PopoverTrigger asChild>
<button
type="button"
data-onborda="dns-blocklist"
disabled={isSaving}
className="flex items-center gap-1.5 h-7 px-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded transition-colors duration-100 w-full text-left disabled:opacity-50"
title={
+29 -1
View File
@@ -16,6 +16,7 @@ import {
LuGroup,
LuKey,
LuLink,
LuLock,
LuLockOpen,
LuPlus,
LuPuzzle,
@@ -341,7 +342,9 @@ export function ProfileInfoDialog({
onClick: () => {
handleAction(() => onConfigureCamoufox?.(profile));
},
disabled: isDisabled,
// Viewing and editing fingerprints both require an active paid plan.
disabled: isDisabled || !crossOsUnlocked,
proBadge: !crossOsUnlocked,
runningBadge: isRunning,
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
},
@@ -481,6 +484,9 @@ export function ProfileInfoDialog({
hideClose
className="sm:max-w-3xl w-[720px] max-w-[720px] h-[480px] max-h-[480px] flex flex-col p-0 gap-0 overflow-hidden"
>
{/* The dialog renders its own custom header, so the accessible title is
visually hidden but present for screen readers (Radix requires it). */}
<DialogTitle className="sr-only">{t("profileInfo.title")}</DialogTitle>
<ProfileInfoLayout
profile={profile}
ProfileIcon={ProfileIcon}
@@ -888,6 +894,7 @@ function ProfileInfoLayout({
// proBadge state. Default to false if action missing.
fingerprintAction && !fingerprintAction.proBadge,
)}
onSaved={onClose}
t={t}
/>
)}
@@ -1586,11 +1593,13 @@ function FingerprintSectionInline({
profile,
isDisabled,
crossOsUnlocked,
onSaved,
t,
}: {
profile: BrowserProfile;
isDisabled: boolean;
crossOsUnlocked: boolean;
onSaved: () => void;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const [camoufoxConfig, setCamoufoxConfig] = React.useState<CamoufoxConfig>(
@@ -1629,6 +1638,23 @@ function FingerprintSectionInline({
);
}
// Viewing and editing fingerprints both require an active paid plan
// (`crossOsUnlocked` is that paid flag here). Render a locked state instead of
// the editor so free users can neither see nor change the fingerprint.
if (!crossOsUnlocked) {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border p-6 text-center">
<LuLock className="size-4 shrink-0 text-muted-foreground" />
<h3 className="text-sm font-medium text-foreground">
{t("profileInfo.fingerprint.lockedTitle")}
</h3>
<p className="max-w-[48ch] text-sm text-pretty text-muted-foreground">
{t("profileInfo.fingerprint.lockedDescription")}
</p>
</div>
);
}
const onCamoufoxChange = (key: keyof CamoufoxConfig, value: unknown) => {
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
setSuccess(null);
@@ -1655,6 +1681,8 @@ function FingerprintSectionInline({
});
}
setSuccess(t("common.buttons.saved"));
// Close the dialog once the fingerprint is saved.
onSaved();
} catch (e) {
setError(String(e));
} finally {
+89 -39
View File
@@ -422,7 +422,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="Mozilla/5.0..."
placeholder={t("common.placeholders.example", {
value: "Mozilla/5.0...",
})}
/>
</div>
<div className="space-y-2">
@@ -436,7 +438,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., MacIntel, Win32"
placeholder={t("common.placeholders.example", {
value: "MacIntel, Win32",
})}
/>
</div>
<div className="space-y-2">
@@ -452,7 +456,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., 5.0 (Macintosh)"
placeholder={t("common.placeholders.example", {
value: "5.0 (Macintosh)",
})}
/>
</div>
<div className="space-y-2">
@@ -487,7 +493,7 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 8"
placeholder={t("common.placeholders.example", { value: "8" })}
/>
</div>
<div className="space-y-2">
@@ -504,7 +510,7 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
<div className="space-y-2">
@@ -549,7 +555,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., en-US"
placeholder={t("common.placeholders.example", {
value: "en-US",
})}
/>
</div>
</div>
@@ -573,7 +581,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1920"
placeholder={t("common.placeholders.example", {
value: "1920",
})}
/>
</div>
<div className="space-y-2">
@@ -590,7 +600,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1080"
placeholder={t("common.placeholders.example", {
value: "1080",
})}
/>
</div>
<div className="space-y-2">
@@ -607,7 +619,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1920"
placeholder={t("common.placeholders.example", {
value: "1920",
})}
/>
</div>
<div className="space-y-2">
@@ -624,7 +638,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1055"
placeholder={t("common.placeholders.example", {
value: "1055",
})}
/>
</div>
<div className="space-y-2">
@@ -641,7 +657,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 30"
placeholder={t("common.placeholders.example", {
value: "30",
})}
/>
</div>
<div className="space-y-2">
@@ -658,7 +676,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 30"
placeholder={t("common.placeholders.example", {
value: "30",
})}
/>
</div>
</div>
@@ -682,7 +702,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1512"
placeholder={t("common.placeholders.example", {
value: "1512",
})}
/>
</div>
<div className="space-y-2">
@@ -699,7 +721,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 886"
placeholder={t("common.placeholders.example", {
value: "886",
})}
/>
</div>
<div className="space-y-2">
@@ -716,7 +740,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1512"
placeholder={t("common.placeholders.example", {
value: "1512",
})}
/>
</div>
<div className="space-y-2">
@@ -733,7 +759,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 886"
placeholder={t("common.placeholders.example", {
value: "886",
})}
/>
</div>
<div className="space-y-2">
@@ -748,7 +776,7 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
<div className="space-y-2">
@@ -763,7 +791,7 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
</div>
@@ -786,7 +814,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 41.0019"
placeholder={t("common.placeholders.example", {
value: "41.0019",
})}
/>
</div>
<div className="space-y-2">
@@ -802,7 +832,9 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 28.9645"
placeholder={t("common.placeholders.example", {
value: "28.9645",
})}
/>
</div>
<div className="space-y-2">
@@ -817,7 +849,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., America/New_York"
placeholder={t("common.placeholders.example", {
value: "America/New_York",
})}
/>
</div>
</div>
@@ -840,7 +874,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., tr"
placeholder={t("common.placeholders.example", {
value: "tr",
})}
/>
</div>
<div className="space-y-2">
@@ -854,7 +890,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., TR"
placeholder={t("common.placeholders.example", {
value: "TR",
})}
/>
</div>
<div className="space-y-2">
@@ -868,7 +906,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., Latn"
placeholder={t("common.placeholders.example", {
value: "Latn",
})}
/>
</div>
</div>
@@ -891,7 +931,9 @@ export function SharedCamoufoxConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., Mesa"
placeholder={t("common.placeholders.example", {
value: "Mesa",
})}
/>
</div>
<div className="space-y-2">
@@ -1053,7 +1095,7 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
<div className="space-y-2">
@@ -1071,7 +1113,7 @@ export function SharedCamoufoxConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
</div>
@@ -1097,10 +1139,10 @@ export function SharedCamoufoxConfigForm({
{limitedMode && (
<>
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-0 flex items-center justify-center z-[3]">
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
<ProBadge />
@@ -1240,7 +1282,9 @@ export function SharedCamoufoxConfigForm({
: undefined,
);
}}
placeholder="e.g., 1920"
placeholder={t("common.placeholders.example", {
value: "1920",
})}
/>
</div>
<div className="space-y-2">
@@ -1259,7 +1303,9 @@ export function SharedCamoufoxConfigForm({
: undefined,
);
}}
placeholder="e.g., 1080"
placeholder={t("common.placeholders.example", {
value: "1080",
})}
/>
</div>
<div className="space-y-2">
@@ -1278,7 +1324,9 @@ export function SharedCamoufoxConfigForm({
: undefined,
);
}}
placeholder="e.g., 800"
placeholder={t("common.placeholders.example", {
value: "800",
})}
/>
</div>
<div className="space-y-2">
@@ -1297,7 +1345,9 @@ export function SharedCamoufoxConfigForm({
: undefined,
);
}}
placeholder="e.g., 600"
placeholder={t("common.placeholders.example", {
value: "600",
})}
/>
</div>
</div>
@@ -1305,10 +1355,10 @@ export function SharedCamoufoxConfigForm({
{limitedMode && (
<>
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-0 flex items-center justify-center z-[3]">
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
<ProBadge />
+83
View File
@@ -0,0 +1,83 @@
"use client";
import confetti from "canvas-confetti";
import { motion } from "motion/react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Logo } from "@/components/icons/logo";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
const spring = { type: "spring", stiffness: 240, damping: 22 } as const;
// Celebratory close-out of the first-run onboarding: thanks the user and fires
// confetti. Shown once the product tour is finished.
export function ThankYouDialog({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
useEffect(() => {
if (!isOpen) return;
const fire = (options: confetti.Options) => {
void confetti({ origin: { y: 0.7 }, ...options });
};
fire({ particleCount: 110, spread: 70, startVelocity: 48 });
const t1 = setTimeout(
() => fire({ particleCount: 70, spread: 100, decay: 0.92 }),
200,
);
const t2 = setTimeout(
() => fire({ particleCount: 50, spread: 120, scalar: 0.9 }),
420,
);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}, [isOpen]);
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="sm:max-w-md">
<div className="flex flex-col items-center gap-6 text-center">
<motion.div
initial={{ opacity: 0, scale: 0.6, rotate: -12 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={{ ...spring, delay: 0.05 }}
className="text-foreground"
>
<Logo className="size-14" />
</motion.div>
<div className="flex flex-col gap-2">
<DialogTitle className="text-2xl font-semibold tracking-tight text-balance">
{t("onboarding.thankYou.title")}
</DialogTitle>
<motion.p
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...spring, delay: 0.15 }}
className="mx-auto max-w-[46ch] text-sm leading-6 text-pretty text-muted-foreground"
>
{t("onboarding.thankYou.body")}
</motion.p>
</div>
<Button size="sm" onClick={onClose}>
{t("onboarding.thankYou.cta")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -312,7 +312,7 @@ export const ColorPickerAlpha = ({
'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==") left center',
}}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent rounded-full to-black/50" />
<div className="absolute inset-0 bg-linear-to-r from-transparent rounded-full to-black/50" />
<Slider.Range className="absolute h-full bg-transparent rounded-full" />
</Slider.Track>
<Slider.Thumb className="block size-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
+42 -28
View File
@@ -111,26 +111,39 @@ function DialogOverlay({
className={cn("fixed inset-0 z-9999 bg-background/50", className)}
{...props}
>
{/* Keep the OS title-bar zone draggable while a modal is open the
overlay otherwise covers the native drag region. `data-window-drag-area`
stops Radix from treating a drag here as an outside-click dismiss. */}
<div
data-tauri-drag-region
data-window-drag-area="true"
aria-hidden="true"
className="absolute inset-x-0 top-0 h-11"
/>
<WindowDragArea />
</motion.div>
</DialogPrimitive.Overlay>
);
}
type DialogFlipDirection = "top" | "bottom" | "left" | "right";
type DialogContentProps = Omit<
React.ComponentProps<typeof DialogPrimitive.Content>,
"forceMount" | "asChild"
> &
HTMLMotionProps<"div"> & {
from?: DialogFlipDirection;
/**
* Suppress the built-in top-right close X. Use when the dialog renders
* its own header bar with a custom close control to avoid two X buttons
* stacking near the corner.
*/
hideClose?: boolean;
/**
* When false, the user cannot dismiss the dialog Escape and outside
* clicks are ignored and the close X is hidden. Use for steps the user
* must complete to progress (e.g. required onboarding, a blocking
* download). The dialog can still be closed programmatically via `open`.
*/
dismissible?: boolean;
};
function SubPageContent({
@@ -176,7 +189,6 @@ function SubPageContent({
function DialogContent({
className,
children,
from = "top",
onOpenAutoFocus,
onCloseAutoFocus,
onEscapeKeyDown,
@@ -184,19 +196,11 @@ function DialogContent({
onInteractOutside,
transition,
hideClose,
dismissible = true,
...props
}: DialogContentProps) {
const { t } = useTranslation();
const { subPage } = useDialog();
const initialRotation =
from === "bottom" || from === "left" ? "20deg" : "-20deg";
const isVertical = from === "top" || from === "bottom";
const rotateAxis = isVertical ? "rotateX" : "rotateY";
const finalTransition = transition ?? {
type: "spring",
stiffness: 220,
damping: 26,
};
if (subPage) {
return <SubPageContent>{children}</SubPageContent>;
@@ -210,9 +214,16 @@ function DialogContent({
forceMount
onOpenAutoFocus={onOpenAutoFocus}
onCloseAutoFocus={onCloseAutoFocus}
onEscapeKeyDown={onEscapeKeyDown}
onEscapeKeyDown={(event) => {
if (!dismissible) event.preventDefault();
onEscapeKeyDown?.(event);
}}
onPointerDownOutside={onPointerDownOutside}
onInteractOutside={(event) => {
if (!dismissible) {
event.preventDefault();
return;
}
const target = event.target as HTMLElement | null;
if (target?.closest('[data-window-drag-area="true"]')) {
event.preventDefault();
@@ -223,22 +234,25 @@ function DialogContent({
<motion.div
key="dialog-content"
data-slot="dialog-content"
initial={{
opacity: 0,
filter: "blur(4px)",
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
}}
animate={{
opacity: 1,
filter: "blur(0px)",
transform: `perspective(500px) ${rotateAxis}(0deg) scale(1)`,
}}
// Open/close motion modeled on transitions.dev's modal: a subtle
// scale from 0.96 → 1 with opacity, eased with cubic-bezier(0.22, 1,
// 0.36, 1). Open is 250ms; close is a quicker 150ms. The centering
// translate stays in `style` so `scale` animates around the center
// without fighting the transform-based positioning.
style={{ transformOrigin: "center" }}
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{
opacity: 0,
filter: "blur(4px)",
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
scale: 0.96,
transition: transition ?? {
duration: 0.15,
ease: [0.22, 1, 0.36, 1],
},
}}
transition={finalTransition}
transition={
transition ?? { duration: 0.25, ease: [0.22, 1, 0.36, 1] }
}
className={cn(
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg",
className,
@@ -246,7 +260,7 @@ function DialogContent({
{...props}
>
{children}
{!hideClose && (
{!hideClose && dismissible && (
<DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<RxCross2 />
<span className="sr-only">{t("common.buttons.close")}</span>
+2 -2
View File
@@ -15,12 +15,12 @@ const Toaster = ({ ...props }: ToasterProps) => {
"--normal-bg": "var(--card)",
"--normal-text": "var(--card-foreground)",
"--normal-border": "var(--border)",
zIndex: 99999,
zIndex: 10001,
} as React.CSSProperties
}
toastOptions={{
style: {
zIndex: 99999,
zIndex: 10001,
pointerEvents: "auto",
backdropFilter: "saturate(1.2)",
},
+98 -42
View File
@@ -302,7 +302,9 @@ export function WayfernConfigForm({
e.target.value || undefined,
);
}}
placeholder="Mozilla/5.0..."
placeholder={t("common.placeholders.example", {
value: "Mozilla/5.0...",
})}
/>
</div>
<div className="space-y-2">
@@ -334,7 +336,9 @@ export function WayfernConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., 10.0.0"
placeholder={t("common.placeholders.example", {
value: "10.0.0",
})}
/>
</div>
<div className="space-y-2">
@@ -348,7 +352,9 @@ export function WayfernConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., Google Chrome"
placeholder={t("common.placeholders.example", {
value: "Google Chrome",
})}
/>
</div>
<div className="space-y-2">
@@ -364,7 +370,9 @@ export function WayfernConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., 143"
placeholder={t("common.placeholders.example", {
value: "143",
})}
/>
</div>
</div>
@@ -388,7 +396,7 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 8"
placeholder={t("common.placeholders.example", { value: "8" })}
/>
</div>
<div className="space-y-2">
@@ -405,7 +413,7 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
<div className="space-y-2">
@@ -422,7 +430,7 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 8"
placeholder={t("common.placeholders.example", { value: "8" })}
/>
</div>
</div>
@@ -446,7 +454,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1920"
placeholder={t("common.placeholders.example", {
value: "1920",
})}
/>
</div>
<div className="space-y-2">
@@ -463,7 +473,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1080"
placeholder={t("common.placeholders.example", {
value: "1080",
})}
/>
</div>
<div className="space-y-2">
@@ -481,7 +493,9 @@ export function WayfernConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 1.0"
placeholder={t("common.placeholders.example", {
value: "1.0",
})}
/>
</div>
<div className="space-y-2">
@@ -498,7 +512,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1920"
placeholder={t("common.placeholders.example", {
value: "1920",
})}
/>
</div>
<div className="space-y-2">
@@ -515,7 +531,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1040"
placeholder={t("common.placeholders.example", {
value: "1040",
})}
/>
</div>
<div className="space-y-2">
@@ -532,7 +550,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 24"
placeholder={t("common.placeholders.example", {
value: "24",
})}
/>
</div>
</div>
@@ -556,7 +576,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1920"
placeholder={t("common.placeholders.example", {
value: "1920",
})}
/>
</div>
<div className="space-y-2">
@@ -573,7 +595,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1040"
placeholder={t("common.placeholders.example", {
value: "1040",
})}
/>
</div>
<div className="space-y-2">
@@ -590,7 +614,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 1920"
placeholder={t("common.placeholders.example", {
value: "1920",
})}
/>
</div>
<div className="space-y-2">
@@ -607,7 +633,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 940"
placeholder={t("common.placeholders.example", {
value: "940",
})}
/>
</div>
<div className="space-y-2">
@@ -622,7 +650,7 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
<div className="space-y-2">
@@ -637,7 +665,7 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 0"
placeholder={t("common.placeholders.example", { value: "0" })}
/>
</div>
</div>
@@ -660,7 +688,9 @@ export function WayfernConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., en-US"
placeholder={t("common.placeholders.example", {
value: "en-US",
})}
/>
</div>
<div className="space-y-2">
@@ -740,7 +770,9 @@ export function WayfernConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., America/New_York"
placeholder={t("common.placeholders.example", {
value: "America/New_York",
})}
/>
</div>
<div className="space-y-2">
@@ -775,7 +807,9 @@ export function WayfernConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 40.7128"
placeholder={t("common.placeholders.example", {
value: "40.7128",
})}
/>
</div>
<div className="space-y-2">
@@ -791,7 +825,9 @@ export function WayfernConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., -74.0060"
placeholder={t("common.placeholders.example", {
value: "-74.0060",
})}
/>
</div>
<div className="space-y-2">
@@ -806,7 +842,9 @@ export function WayfernConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 100"
placeholder={t("common.placeholders.example", {
value: "100",
})}
/>
</div>
</div>
@@ -829,7 +867,9 @@ export function WayfernConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., Intel"
placeholder={t("common.placeholders.example", {
value: "Intel",
})}
/>
</div>
<div className="space-y-2">
@@ -926,7 +966,9 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 48000"
placeholder={t("common.placeholders.example", {
value: "48000",
})}
/>
</div>
<div className="space-y-2">
@@ -943,7 +985,7 @@ export function WayfernConfigForm({
e.target.value ? parseInt(e.target.value, 10) : undefined,
);
}}
placeholder="e.g., 2"
placeholder={t("common.placeholders.example", { value: "2" })}
/>
</div>
</div>
@@ -987,7 +1029,9 @@ export function WayfernConfigForm({
e.target.value ? parseFloat(e.target.value) : undefined,
);
}}
placeholder="e.g., 0.85"
placeholder={t("common.placeholders.example", {
value: "0.85",
})}
/>
</div>
</div>
@@ -1008,7 +1052,9 @@ export function WayfernConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., Google Inc."
placeholder={t("common.placeholders.example", {
value: "Google Inc.",
})}
/>
</div>
<div className="space-y-2">
@@ -1038,7 +1084,9 @@ export function WayfernConfigForm({
e.target.value || undefined,
);
}}
placeholder="e.g., 20030107"
placeholder={t("common.placeholders.example", {
value: "20030107",
})}
/>
</div>
</div>
@@ -1047,10 +1095,10 @@ export function WayfernConfigForm({
{limitedMode && (
<>
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-0 flex items-center justify-center z-[3]">
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
<ProBadge />
@@ -1197,7 +1245,9 @@ export function WayfernConfigForm({
: undefined,
);
}}
placeholder="e.g., 1920"
placeholder={t("common.placeholders.example", {
value: "1920",
})}
/>
</div>
<div className="space-y-2">
@@ -1216,7 +1266,9 @@ export function WayfernConfigForm({
: undefined,
);
}}
placeholder="e.g., 1080"
placeholder={t("common.placeholders.example", {
value: "1080",
})}
/>
</div>
<div className="space-y-2">
@@ -1235,7 +1287,9 @@ export function WayfernConfigForm({
: undefined,
);
}}
placeholder="e.g., 800"
placeholder={t("common.placeholders.example", {
value: "800",
})}
/>
</div>
<div className="space-y-2">
@@ -1254,7 +1308,9 @@ export function WayfernConfigForm({
: undefined,
);
}}
placeholder="e.g., 600"
placeholder={t("common.placeholders.example", {
value: "600",
})}
/>
</div>
</div>
@@ -1262,10 +1318,10 @@ export function WayfernConfigForm({
{limitedMode && (
<>
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
<div className="absolute inset-0 flex items-center justify-center z-[3]">
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
<ProBadge />
+484
View File
@@ -0,0 +1,484 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {
LuArrowRight,
LuBriefcase,
LuCookie,
LuFolders,
LuGithub,
LuGlobe,
LuHeart,
LuLoaderCircle,
LuMic,
LuNetwork,
LuShieldCheck,
LuTerminal,
LuTriangleAlert,
LuUsers,
} from "react-icons/lu";
import { Logo } from "@/components/icons/logo";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { useBrowserSetup } from "@/hooks/use-browser-setup";
import { usePermissions } from "@/hooks/use-permissions";
import { getBrowserDisplayName } from "@/lib/browser-utils";
type WelcomeStep = "intro" | "license" | "permissions" | "setup";
const panelTransition = {
type: "spring",
stiffness: 260,
damping: 28,
} as const;
const panelVariants = {
enter: { opacity: 0, y: 12 },
center: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -12 },
};
// Concrete feature list shown on the intro step, rendered as an icon grid.
const FEATURES = [
{ key: "welcome.features.items.setDefault", Icon: LuGlobe },
{ key: "welcome.features.items.proxy", Icon: LuNetwork },
{ key: "welcome.features.items.vpn", Icon: LuShieldCheck },
{ key: "welcome.features.items.profiles", Icon: LuUsers },
{ key: "welcome.features.items.api", Icon: LuTerminal },
{ key: "welcome.features.items.openSource", Icon: LuGithub },
{ key: "welcome.features.items.groups", Icon: LuFolders },
{ key: "welcome.features.items.cookies", Icon: LuCookie },
] as const;
function formatBytes(bytes: number): string {
if (!(bytes > 0)) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const exponent = Math.min(
units.length - 1,
Math.floor(Math.log(bytes) / Math.log(1024)),
);
const value = bytes / 1024 ** exponent;
const rounded = exponent === 0 ? value : Math.round(value * 10) / 10;
return `${rounded} ${units[exponent]}`;
}
function formatDuration(seconds: number): string {
const total = Math.max(0, Math.round(seconds));
if (total < 60) return `${total}s`;
const minutes = Math.floor(total / 60);
const remainder = total % 60;
return `${minutes}m ${String(remainder).padStart(2, "0")}s`;
}
export function WelcomeDialog({
isOpen,
needsSetup,
onComplete,
}: {
isOpen: boolean;
/**
* Whether this user still needs the browser-download + profile-creation flow.
* False when they already have a profile then the welcome and commercial-use
* steps still show, but "continue" finishes onboarding instead of proceeding
* to permissions/download.
*/
needsSetup: boolean;
onComplete: () => void;
}) {
const { t } = useTranslation();
const { requestPermission } = usePermissions();
const [step, setStep] = useState<WelcomeStep>("intro");
// Where the "skip" / "continue" affordances go: into the setup flow when a
// browser/profile is still needed, otherwise straight to completion.
const advanceToSetup = () => {
if (needsSetup) setStep("setup");
else onComplete();
};
const [requesting, setRequesting] = useState(false);
// Track the required browser's download + extraction the whole time the
// dialog is open, so progress is live by the time the user reaches setup.
const setup = useBrowserSetup("wayfern", isOpen);
const browserName = getBrowserDisplayName("wayfern");
const requestPermissions = useCallback(async () => {
setRequesting(true);
try {
await requestPermission("microphone");
await requestPermission("camera");
} catch (err) {
console.error("Permission request failed:", err);
} finally {
setRequesting(false);
setStep("setup");
}
}, [requestPermission]);
return (
<Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent
dismissible={false}
className="overflow-hidden sm:max-w-xl"
>
<DialogTitle className="sr-only">{t("welcome.title")}</DialogTitle>
<AnimatePresence mode="wait">
{step === "intro" && (
<motion.div
key="intro"
variants={panelVariants}
initial="enter"
animate="center"
exit="exit"
transition={panelTransition}
className="flex flex-col gap-7"
>
<div className="flex flex-col items-center gap-4 text-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ ...panelTransition, delay: 0.05 }}
className="text-foreground"
>
<Logo className="size-12" />
</motion.div>
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-semibold tracking-tight text-balance">
{t("welcome.title")}
</h2>
<p className="mx-auto max-w-[55ch] text-sm text-pretty text-muted-foreground">
{t("welcome.tagline")}
</p>
</div>
</div>
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-muted-foreground">
{t("welcome.features.title")}
</p>
<dl className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
{FEATURES.map(({ key, Icon }, i) => (
<motion.div
key={key}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{
...panelTransition,
delay: 0.12 + i * 0.04,
}}
className="flex items-center gap-2.5"
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<dt className="text-sm font-medium text-foreground">
{t(key)}
</dt>
</motion.div>
))}
</dl>
</div>
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={advanceToSetup}
>
{t("welcome.skip")}
</Button>
<Button
size="sm"
className="gap-1.5"
onClick={() => setStep("license")}
>
{t("welcome.next")}
<LuArrowRight className="size-4 shrink-0" />
</Button>
</div>
</motion.div>
)}
{step === "license" && (
<motion.div
key="license"
variants={panelVariants}
initial="enter"
animate="center"
exit="exit"
transition={panelTransition}
className="flex flex-col gap-7"
>
<div className="flex flex-col gap-2 text-center">
<h2 className="text-2xl font-semibold tracking-tight text-balance">
{t("welcome.license.title")}
</h2>
<p className="mx-auto max-w-[55ch] text-sm leading-6 text-pretty text-muted-foreground">
{t("welcome.license.body")}
</p>
</div>
<dl className="flex flex-col gap-3">
<div className="flex items-start gap-3 rounded-lg border p-4">
<LuHeart className="mt-0.5 size-4 shrink-0 text-success" />
<div className="flex flex-col gap-0.5 text-left">
<dt className="text-sm font-medium text-foreground">
{t("welcome.license.personalTitle")}
</dt>
<dd className="text-sm text-pretty text-muted-foreground">
{t("welcome.license.personalDesc")}
</dd>
</div>
</div>
<div className="flex items-start gap-3 rounded-lg border p-4">
<LuBriefcase className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col gap-0.5 text-left">
<dt className="flex items-center gap-2 text-sm font-medium text-foreground">
{t("welcome.license.commercialTitle")}
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{t("welcome.license.trialBadge")}
</span>
</dt>
<dd className="text-sm text-pretty text-muted-foreground">
{t("welcome.license.commercialDesc")}
</dd>
</div>
</div>
</dl>
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={advanceToSetup}
>
{t("welcome.skip")}
</Button>
<Button
size="sm"
className="gap-1.5"
onClick={() => {
if (needsSetup) setStep("permissions");
else onComplete();
}}
>
{t("welcome.license.agree")}
<LuArrowRight className="size-4 shrink-0" />
</Button>
</div>
</motion.div>
)}
{step === "permissions" && (
<motion.div
key="permissions"
variants={panelVariants}
initial="enter"
animate="center"
exit="exit"
transition={panelTransition}
className="flex flex-col gap-7"
>
<div className="flex flex-col gap-2 text-center">
<h2 className="flex items-center justify-center gap-2 text-2xl font-semibold tracking-tight text-balance">
<LuMic className="size-5 shrink-0" />
{t("welcome.permissions.title")}
</h2>
<p className="mx-auto max-w-[55ch] text-sm leading-6 text-pretty text-muted-foreground">
{t("welcome.permissions.desc")}
</p>
</div>
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
disabled={requesting}
onClick={advanceToSetup}
>
{t("welcome.permissions.skip")}
</Button>
<Button
size="sm"
className="gap-1.5"
disabled={requesting}
onClick={() => {
void requestPermissions();
}}
>
{requesting && (
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
)}
{requesting
? t("welcome.permissions.requesting")
: t("welcome.permissions.grant")}
</Button>
</div>
</motion.div>
)}
{step === "setup" && (
<motion.div
key="setup"
variants={panelVariants}
initial="enter"
animate="center"
exit="exit"
transition={panelTransition}
className="flex flex-col items-center gap-6 text-center"
>
{setup.phase === "error" ? (
<>
<div className="flex flex-col items-center gap-2">
<h2 className="flex items-center justify-center gap-2 text-2xl font-semibold tracking-tight text-balance text-destructive">
<LuTriangleAlert className="size-5 shrink-0" />
{t("welcome.ready.errorTitle")}
</h2>
<p className="max-w-[55ch] text-sm leading-6 text-pretty text-muted-foreground">
{setup.error?.stage === "downloading"
? t("welcome.ready.errorDownload", {
browser: browserName,
})
: setup.error?.stage === "extracting" ||
setup.error?.stage === "verifying"
? t("welcome.ready.errorExtraction", {
browser: browserName,
})
: t("welcome.ready.errorGeneric", {
browser: browserName,
})}
</p>
</div>
{/* No escape hatch here: a browser must finish downloading
before onboarding can complete, so the only action on
failure is to retry. */}
<Button
size="sm"
onClick={() => {
setup.retry();
}}
>
{t("welcome.ready.retry")}
</Button>
</>
) : (
<>
<div className="flex flex-col items-center gap-2">
<h2 className="text-2xl font-semibold tracking-tight text-balance">
{t("welcome.ready.title")}
</h2>
<p className="max-w-[55ch] text-sm leading-6 text-pretty text-muted-foreground">
{setup.phase === "ready"
? t("welcome.ready.descReady")
: setup.phase === "extracting"
? t("welcome.ready.descExtracting")
: t("welcome.ready.descDownloading")}
</p>
</div>
{setup.phase === "downloading" && (
<div className="flex w-full max-w-xs flex-col gap-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full rounded-full bg-primary"
initial={{ width: 0 }}
animate={{
width: `${Math.max(setup.downloadPercent, 4)}%`,
}}
transition={{
type: "spring",
stiffness: 120,
damping: 24,
}}
/>
</div>
<div className="flex items-center justify-between text-sm tabular-nums text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
{t("welcome.ready.downloading")}
</span>
<span>{setup.downloadPercent}%</span>
</div>
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-0.5 text-xs tabular-nums text-muted-foreground">
<span>
{setup.totalBytes != null
? t("welcome.ready.stats", {
downloaded: formatBytes(setup.downloadedBytes),
total: formatBytes(setup.totalBytes),
})
: formatBytes(setup.downloadedBytes)}
</span>
{setup.speedBytesPerSec > 0 && (
<span>
{t("welcome.ready.speed", {
speed: formatBytes(setup.speedBytesPerSec),
})}
</span>
)}
{setup.etaSeconds != null &&
Number.isFinite(setup.etaSeconds) &&
setup.etaSeconds > 0 && (
<span>
{t("welcome.ready.timeLeft", {
time: formatDuration(setup.etaSeconds),
})}
</span>
)}
</div>
</div>
)}
{setup.phase === "extracting" && (
<div className="flex w-full max-w-xs flex-col gap-2">
{setup.extractionOvertime ? (
<div className="flex items-center justify-center gap-1.5 text-sm tabular-nums text-muted-foreground">
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
{t("welcome.ready.almostFinished")}
</div>
) : (
<>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full rounded-full bg-primary"
initial={{ width: 0 }}
animate={{
width: `${Math.max(setup.extractionPercent, 4)}%`,
}}
transition={{
type: "spring",
stiffness: 120,
damping: 24,
}}
/>
</div>
<div className="flex items-center justify-between text-sm tabular-nums text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
{t("welcome.ready.extracting")}
</span>
<span>{setup.extractionPercent}%</span>
</div>
</>
)}
</div>
)}
{setup.phase === "ready" && (
<Button size="sm" className="gap-1.5" onClick={onComplete}>
<LuArrowRight className="size-4 shrink-0" />
{t("welcome.ready.cta")}
</Button>
)}
</>
)}
</motion.div>
)}
</AnimatePresence>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -71,7 +71,7 @@ export function useAppUpdateNotifications() {
percentage: 0,
speed: undefined,
eta: undefined,
message: "Starting update...",
message: t("appUpdate.toast.startingUpdate"),
});
await invoke("download_and_prepare_app_update", {
+51 -36
View File
@@ -4,6 +4,7 @@ import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import i18n from "@/i18n";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import { isOnboardingActive } from "@/lib/onboarding-signal";
import {
dismissToast,
showDownloadToast,
@@ -327,31 +328,39 @@ export function useBrowserDownload() {
: i18n.t("browserDownload.toast.calculating");
const toastId = `download-${browserName.toLowerCase()}-${progress.version}`;
showDownloadToast(
browserName,
progress.version,
"downloading",
{
percentage: progress.percentage,
speed: speedMBps,
eta: etaText,
},
{
onCancel: () => {
invoke("cancel_download", {
browserStr: progress.browser,
version: progress.version,
}).catch((err) => {
console.error("Failed to cancel download:", err);
});
dismissToast(toastId);
// During first-run onboarding the welcome dialog shows browser
// setup progress itself, so suppress the global download toast.
if (!isOnboardingActive()) {
showDownloadToast(
browserName,
progress.version,
"downloading",
{
percentage: progress.percentage,
speed: speedMBps,
eta: etaText,
},
},
);
{
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");
if (!isOnboardingActive()) {
showDownloadToast(browserName, progress.version, "extracting");
}
} else if (progress.stage === "verifying") {
showDownloadToast(browserName, progress.version, "verifying");
if (!isOnboardingActive()) {
showDownloadToast(browserName, progress.version, "verifying");
}
} else if (progress.stage === "cancelled") {
setDownloadingBrowsers((prev) => {
const next = new Set(prev);
@@ -372,17 +381,21 @@ export function useBrowserDownload() {
`download-${browserName.toLowerCase()}-${progress.version}`,
);
setDownloadProgress(null);
showErrorToast(
i18n.t("browserDownload.toast.extractionFailed", {
browser: browserName,
version: progress.version,
}),
{
description: i18n.t(
"browserDownload.toast.extractionFailedDescription",
),
},
);
// During first-run onboarding the welcome dialog surfaces a
// concrete setup error itself, so suppress the global toast.
if (!isOnboardingActive()) {
showErrorToast(
i18n.t("browserDownload.toast.extractionFailed", {
browser: browserName,
version: progress.version,
}),
{
description: i18n.t(
"browserDownload.toast.extractionFailedDescription",
),
},
);
}
} else if (progress.stage === "completed") {
setDownloadingBrowsers((prev) => {
const next = new Set(prev);
@@ -401,7 +414,9 @@ export function useBrowserDownload() {
} catch {
/* empty */
}
showDownloadToast(browserName, progress.version, "completed");
if (!isOnboardingActive()) {
showDownloadToast(browserName, progress.version, "completed");
}
setDownloadProgress(null);
}
},
@@ -443,7 +458,7 @@ export function useBrowserDownload() {
showToast({
id: "geoip-download",
type: "download",
title: "Downloading GeoIP database",
title: i18n.t("browserDownload.toast.geoipDownloading"),
stage: "downloading",
progress: {
percentage,
@@ -455,7 +470,7 @@ export function useBrowserDownload() {
showToast({
id: "geoip-download",
type: "download",
title: "GeoIP database downloaded successfully!",
title: i18n.t("browserDownload.toast.geoipDownloaded"),
stage: "completed",
});
}
+342
View File
@@ -0,0 +1,342 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useRef, useState } from "react";
interface DownloadProgress {
browser: string;
version: string;
downloaded_bytes: number;
total_bytes: number | null;
percentage: number;
speed_bytes_per_sec: number;
eta_seconds?: number | null;
stage: string;
}
export type SetupPhase = "downloading" | "extracting" | "ready" | "error";
export type SetupErrorStage =
| "downloading"
| "extracting"
| "verifying"
| "other";
export interface SetupError {
stage: SetupErrorStage;
}
// The backend emits a real percentage only while downloading; extraction sends
// a single "extracting" event with no incremental progress (it takes ~2 min).
// So we estimate extraction progress from elapsed time vs. a learned average,
// seeded at 2 minutes and refined with the real durations we record.
const DEFAULT_EXTRACT_MS = 2 * 60 * 1000;
const MAX_SAMPLES = 5; // the 2-min seed + up to 4 most recent real durations
const storageKey = (browser: string) => `donut.extractDurations.${browser}`;
function readDurations(browser: string): number[] {
try {
const raw = localStorage.getItem(storageKey(browser));
const arr = raw ? (JSON.parse(raw) as unknown) : null;
if (
Array.isArray(arr) &&
arr.length > 0 &&
arr.every((n) => typeof n === "number" && n > 0)
) {
return arr as number[];
}
} catch {
// fall through to the seed
}
return [DEFAULT_EXTRACT_MS];
}
function recordDuration(browser: string, ms: number) {
if (!(ms > 0)) return;
const current = readDurations(browser);
// Keep the 2-min seed as the first value, then the most recent real samples.
const samples =
current[0] === DEFAULT_EXTRACT_MS ? current.slice(1) : current;
const next = [
DEFAULT_EXTRACT_MS,
...[...samples, ms].slice(-(MAX_SAMPLES - 1)),
];
try {
localStorage.setItem(storageKey(browser), JSON.stringify(next));
} catch {
// ignore persistence failures
}
}
function average(values: number[]): number {
return values.reduce((a, b) => a + b, 0) / values.length;
}
// Map a backend stage to the error stage we report when something fails.
function toErrorStage(stage: string): SetupErrorStage {
switch (stage) {
case "downloading":
return "downloading";
case "extracting":
return "extracting";
case "verifying":
return "verifying";
default:
return "other";
}
}
/**
* Tracks first-launch setup of a browser: real download progress plus an
* estimated extraction progress (no countdown timer, percentages only).
* `active` should be true while the owning dialog is open.
*/
export function useBrowserSetup(browser: string, active: boolean) {
const [phase, setPhase] = useState<SetupPhase>("downloading");
// Download metrics straight from the latest "downloading" event.
const [downloadPercent, setDownloadPercent] = useState(0);
const [downloadedBytes, setDownloadedBytes] = useState(0);
const [totalBytes, setTotalBytes] = useState<number | null>(null);
const [speedBytesPerSec, setSpeedBytesPerSec] = useState(0);
const [etaSeconds, setEtaSeconds] = useState<number | null>(null);
// Estimated extraction progress (percentages only, capped at 99 until done).
const [extractionPercent, setExtractionPercent] = useState(0);
const [extractionOvertime, setExtractionOvertime] = useState(false);
const [error, setError] = useState<SetupError | null>(null);
const extractStartRef = useRef<number | null>(null);
const estimateRef = useRef(DEFAULT_EXTRACT_MS);
// Fallback bookkeeping so a listener that mounts mid-flight (and therefore
// misses the single "extracting" event) can still show extraction progress.
const sawDownloadingRef = useRef(false);
const lastProgressAtRef = useRef<number | null>(null);
const lastDownloadPercentRef = useRef(0);
// The last non-terminal stage we observed, used to label an error.
const lastStageRef = useRef<string>("downloading");
// Set once a terminal state (ready/error) is reached. Stops the tick so the
// mid-flight extraction fallback can't re-arm and fight the readiness poll
// (which would oscillate "ready" ↔ "Almost finished" forever).
const doneRef = useRef(false);
useEffect(() => {
if (!active) {
// Fully reset when the owning dialog closes.
setPhase("downloading");
setDownloadPercent(0);
setDownloadedBytes(0);
setTotalBytes(null);
setSpeedBytesPerSec(0);
setEtaSeconds(null);
setExtractionPercent(0);
setExtractionOvertime(false);
setError(null);
extractStartRef.current = null;
sawDownloadingRef.current = false;
lastProgressAtRef.current = null;
lastDownloadPercentRef.current = 0;
lastStageRef.current = "downloading";
doneRef.current = false;
return;
}
let alive = true;
estimateRef.current = average(readDurations(browser));
extractStartRef.current = null;
sawDownloadingRef.current = false;
lastProgressAtRef.current = null;
lastDownloadPercentRef.current = 0;
lastStageRef.current = "downloading";
doneRef.current = false;
const finishExtraction = () => {
if (extractStartRef.current != null) {
recordDuration(browser, Date.now() - extractStartRef.current);
extractStartRef.current = null;
}
};
const unlistenPromise = listen<DownloadProgress>(
"download-progress",
(event) => {
if (!alive) return;
const p = event.payload;
if (p.browser !== browser) return;
switch (p.stage) {
case "downloading":
lastStageRef.current = "downloading";
sawDownloadingRef.current = true;
lastProgressAtRef.current = Date.now();
lastDownloadPercentRef.current = p.percentage;
setPhase("downloading");
setDownloadPercent(Math.round(p.percentage));
setDownloadedBytes(p.downloaded_bytes);
setTotalBytes(p.total_bytes ?? null);
setSpeedBytesPerSec(p.speed_bytes_per_sec);
setEtaSeconds(p.eta_seconds ?? null);
break;
case "extracting":
lastStageRef.current = "extracting";
if (extractStartRef.current == null) {
extractStartRef.current = Date.now();
}
lastProgressAtRef.current = Date.now();
setPhase("extracting");
break;
case "verifying":
lastStageRef.current = "verifying";
finishExtraction();
// Verification is the tail of extraction; keep the bar near full
// but don't claim "ready" until "completed" arrives.
setPhase("extracting");
setExtractionPercent(99);
break;
case "completed":
doneRef.current = true;
finishExtraction();
setPhase("ready");
setExtractionPercent(100);
setExtractionOvertime(false);
setError(null);
break;
case "error":
doneRef.current = true;
finishExtraction();
setPhase("error");
setError({ stage: toErrorStage(lastStageRef.current) });
break;
case "cancelled":
// Treat a cancellation like an error so the dialog can offer retry.
doneRef.current = true;
finishExtraction();
setPhase("error");
setError({ stage: "other" });
break;
default:
break;
}
},
);
// Authoritative completion signal: poll the registry. The "completed" event
// is only a fast-path — we never rely on it alone. This MUST be a recurring
// interval rather than a one-shot loop: independent firings mean a single
// invoke that stalls during heavy extraction can't kill detection, it keeps
// confirming readiness so retry() re-detects an already-downloaded browser
// without restarting the effect, and it covers a browser downloaded before
// this hook mounted. setPhase("ready") is idempotent, so re-confirming is
// free (React bails out when state is unchanged).
let checkingReady = false;
const checkReady = async () => {
if (!alive || checkingReady) return;
checkingReady = true;
try {
const versions = await invoke<string[]>(
"get_downloaded_browser_versions",
{ browserStr: browser },
);
if (alive && versions.length > 0) {
doneRef.current = true;
finishExtraction();
setPhase("ready");
setExtractionPercent(100);
setExtractionOvertime(false);
setError(null);
}
} catch (err) {
console.error("Failed to check browser download status:", err);
} finally {
checkingReady = false;
}
};
void checkReady();
const readyPoll = setInterval(() => {
void checkReady();
}, 1000);
// Drive the estimated extraction percentage while extracting.
const tick = setInterval(() => {
if (!alive || doneRef.current) return;
// If the download visibly finished but we never saw the (single)
// "extracting" event, start estimating extraction anyway — anchored to
// the last download event, which is roughly when extraction began.
if (
extractStartRef.current == null &&
sawDownloadingRef.current &&
lastDownloadPercentRef.current >= 99 &&
lastProgressAtRef.current != null &&
Date.now() - lastProgressAtRef.current > 1200
) {
extractStartRef.current = lastProgressAtRef.current;
lastStageRef.current = "extracting";
setPhase("extracting");
}
if (extractStartRef.current == null) return;
const elapsed = Date.now() - extractStartRef.current;
const est = estimateRef.current || DEFAULT_EXTRACT_MS;
if (elapsed >= est) {
// We've blown past the estimate — hold at 99 and flag overtime so the
// dialog can show "Almost finished" instead of a stalled number.
setExtractionPercent(99);
setExtractionOvertime(true);
} else {
setExtractionPercent(Math.min(99, Math.round((elapsed / est) * 100)));
setExtractionOvertime(false);
}
}, 250);
return () => {
alive = false;
clearInterval(tick);
clearInterval(readyPoll);
void unlistenPromise.then((u) => {
u();
});
};
}, [browser, active]);
const retry = useCallback(() => {
// Reset visible state and the bookkeeping refs, then kick off the download
// again. The effect's event listener and registry poll stay alive the whole
// time the dialog is open, so they pick up the fresh attempt — no need to
// restart the effect.
setPhase("downloading");
setDownloadPercent(0);
setDownloadedBytes(0);
setTotalBytes(null);
setSpeedBytesPerSec(0);
setEtaSeconds(null);
setExtractionPercent(0);
setExtractionOvertime(false);
setError(null);
extractStartRef.current = null;
sawDownloadingRef.current = false;
lastProgressAtRef.current = null;
lastDownloadPercentRef.current = 0;
lastStageRef.current = "downloading";
doneRef.current = false;
void (async () => {
try {
await invoke("ensure_active_browsers_downloaded");
} catch (err) {
console.error("Failed to re-trigger browser setup:", err);
setPhase("error");
setError({ stage: "other" });
}
})();
}, []);
return {
phase,
downloadPercent,
downloadedBytes,
totalBytes,
speedBytesPerSec,
etaSeconds,
extractionPercent,
extractionOvertime,
ready: phase === "ready",
error,
retry,
};
}
+35 -13
View File
@@ -62,8 +62,12 @@ export function useUpdateNotifications(
showToast({
id: `auto-update-started-${browser}-${newVersion}`,
type: "loading",
title: `${browserDisplayName} update started`,
description: `Version ${newVersion} download will begin shortly. Browser launch is disabled until update completes.`,
title: i18n.t("versionUpdater.toast.updateStarted", {
browser: browserDisplayName,
}),
description: i18n.t("versionUpdater.toast.updateStartedDescription", {
version: newVersion,
}),
duration: 4000,
});
@@ -83,8 +87,11 @@ export function useUpdateNotifications(
showToast({
id: `auto-update-skip-download-${browser}-${newVersion}`,
type: "success",
title: `${browserDisplayName} ${newVersion} already available`,
description: "Updating profile configurations...",
title: i18n.t("versionUpdater.toast.alreadyAvailable", {
browser: browserDisplayName,
version: newVersion,
}),
description: i18n.t("versionUpdater.toast.updatingProfiles"),
duration: 3000,
});
} else {
@@ -92,8 +99,11 @@ export function useUpdateNotifications(
showToast({
id: `auto-update-download-starting-${browser}-${newVersion}`,
type: "loading",
title: `Starting ${browserDisplayName} ${newVersion} download`,
description: "Download progress will be shown below...",
title: i18n.t("versionUpdater.toast.downloadStarting", {
browser: browserDisplayName,
version: newVersion,
}),
description: i18n.t("versionUpdater.toast.downloadProgressBelow"),
duration: 4000,
});
@@ -115,24 +125,36 @@ export function useUpdateNotifications(
// Show success message based on whether profiles were updated
if (updatedProfiles.length > 0) {
const profileText =
const description =
updatedProfiles.length === 1
? `Profile "${updatedProfiles[0]}" has been updated`
: `${updatedProfiles.length} profiles have been updated`;
? i18n.t("versionUpdater.toast.singleProfileUpdated", {
name: updatedProfiles[0],
version: newVersion,
})
: i18n.t("versionUpdater.toast.multipleProfilesUpdated", {
count: updatedProfiles.length,
version: newVersion,
});
showToast({
id: `auto-update-success-${browser}-${newVersion}`,
type: "success",
title: `${browserDisplayName} update completed`,
description: `${profileText} to version ${newVersion}. You can now launch your browsers with the latest version.`,
title: i18n.t("versionUpdater.toast.updateCompleted", {
browser: browserDisplayName,
}),
description,
duration: 6000,
});
} else {
showToast({
id: `auto-update-success-${browser}-${newVersion}`,
type: "success",
title: `${browserDisplayName} update completed`,
description: `Version ${newVersion} is now available. Running profiles will use the new version when restarted.`,
title: i18n.t("versionUpdater.toast.updateCompleted", {
browser: browserDisplayName,
}),
description: i18n.t("versionUpdater.toast.versionAvailable", {
version: newVersion,
}),
duration: 6000,
});
}
+7 -1
View File
@@ -139,7 +139,13 @@ export function useVersionUpdater() {
try {
// Show auto-update start notification
showAutoUpdateToast(browserDisplayName, new_version, {
description: `Downloading ${browserDisplayName} ${new_version} automatically. Progress will be shown below.`,
description: i18n.t(
"versionUpdater.toast.autoDownloadStarted",
{
browser: browserDisplayName,
version: new_version,
},
),
});
// Dismiss the update notification in the backend
+120 -21
View File
@@ -33,7 +33,8 @@
"minimize": "Minimize",
"saving": "Saving…",
"saved": "Saved",
"copied": "Copied"
"copied": "Copied",
"learnMore": "Learn more"
},
"status": {
"active": "Active",
@@ -99,6 +100,9 @@
"srOnly": {
"copy": "Copy",
"copied": "Copied"
},
"placeholders": {
"example": "e.g., {{value}}"
}
},
"settings": {
@@ -340,7 +344,8 @@
"downloading": "Downloading {{browser}} version ({{version}})...",
"latestNeedsDownload": "Latest version ({{version}}) needs to be downloaded",
"latestAvailable": "Latest version ({{version}}) is available",
"latestDownloading": "Downloading version ({{version}})..."
"latestDownloading": "Downloading version ({{version}})...",
"upgradeAvailable": "A newer version ({{version}}) of {{browser}} is available."
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "Powered by Wayfern",
@@ -351,7 +356,9 @@
"passwordProtect": {
"label": "Password protect this profile",
"description": "Encrypts the on-disk profile data. Required to launch."
}
},
"downloadingSubtitle": "Downloading…",
"browsersDownloading": "Browsers are still downloading. Profile creation will be available once a download finishes."
},
"deleteDialog": {
"title": "Delete Profile",
@@ -1188,7 +1195,9 @@
"cannotWhileRunning": "Stop the profile before changing its password."
},
"fingerprint": {
"notSupported": "Fingerprint editing is only available for Camoufox and Wayfern profiles."
"notSupported": "Fingerprint editing is only available for Camoufox and Wayfern profiles.",
"lockedTitle": "Fingerprint is a Pro feature",
"lockedDescription": "Viewing and editing a profile's fingerprint requires an active paid plan. Upgrade to unlock fingerprint protection."
}
},
"extensions": {
@@ -1524,17 +1533,6 @@
"creatingButton": "Creating...",
"createButton": "Create"
},
"launchOnLogin": {
"title": "Enable Launch on Login?",
"description": "Running in the background helps keep your proxies and browsers alive.",
"declineButton": "Don't Ask Again",
"declining": "...",
"enableButton": "Enable",
"enableSuccess": "Launch on login enabled",
"enableFailed": "Failed to enable launch on login",
"declineFailed": "Failed to save preference",
"tryAgain": "Please try again"
},
"wayfernTerms": {
"title": "Wayfern Terms and Conditions",
"description": "Before using Donut Browser, you must read and agree to Wayfern's Terms and Conditions.",
@@ -1680,7 +1678,8 @@
"viewRelease": "View Release",
"later": "Later",
"uploading": "Uploading",
"downloading": "Downloading"
"downloading": "Downloading",
"startingUpdate": "Starting update..."
}
},
"browserDownload": {
@@ -1694,7 +1693,9 @@
"extractionFailedDescription": "The corrupt file was deleted. It will be re-downloaded on next attempt.",
"extracting": "Extracting browser files... Please do not close the app.",
"verifying": "Verifying browser files...",
"downloadingRolling": "Downloading rolling release build..."
"downloadingRolling": "Downloading rolling release build...",
"geoipDownloading": "Downloading GeoIP database",
"geoipDownloaded": "GeoIP database downloaded successfully!"
}
},
"versionUpdater": {
@@ -1712,7 +1713,12 @@
"updateSuccessDescription": "Found {{newVersions}} new versions across {{successfulUpdates}} browsers. Auto-downloads will start shortly.",
"upToDate": "No new browser versions found",
"upToDateDescription": "All browser versions are up to date",
"updateAllFailed": "Failed to update browser versions"
"updateAllFailed": "Failed to update browser versions",
"updateStarted": "{{browser}} update started",
"updateStartedDescription": "Version {{version}} download will begin shortly. Browser launch is disabled until update completes.",
"downloadStarting": "Starting {{browser}} {{version}} download",
"downloadProgressBelow": "Download progress will be shown below...",
"autoDownloadStarted": "Downloading {{browser}} {{version}} automatically. Progress will be shown below."
}
},
"profilePassword": {
@@ -1800,7 +1806,11 @@
"invalidLaunchHookUrl": "Invalid launch hook URL. Use a full http:// or https:// URL.",
"cookieDbLocked": "Could not read cookies — the database is locked. Close the browser and try again.",
"cookieDbUnavailable": "Could not read cookies — the cookie store is unavailable.",
"selfHostedRequiresLogout": "Sign out of your Donut account before configuring a self-hosted server."
"selfHostedRequiresLogout": "Sign out of your Donut account before configuring a self-hosted server.",
"fingerprintRequiresPro": "Fingerprint protection requires an active paid plan.",
"proxyNotWorking": "The selected proxy isn't working, so the profile wasn't created.",
"proxyPaymentRequired": "The selected proxy requires payment (402) — its subscription may have expired — so the profile wasn't created.",
"vpnNotWorking": "The selected VPN isn't working, so the profile wasn't created."
},
"rail": {
"profiles": "Profiles",
@@ -1866,7 +1876,8 @@
"plan": "Plan",
"status": "Status",
"teamRole": "Team role",
"period": "Billing period"
"period": "Billing period",
"device": "Device"
},
"tabs": {
"account": "Account",
@@ -1880,7 +1891,10 @@
"statusUnknown": "Untested",
"testConnection": "Test connection",
"disconnect": "Disconnect"
}
},
"deviceOrdinal": "{{ordinal}} of {{count}}",
"automationPrimaryOnly": "Browser automation runs only on your primary device (Device 1). Sign out there to use it here.",
"automationActiveHere": "Browser automation is active on this device."
},
"shortcutsPage": {
"title": "Keyboard shortcuts",
@@ -1918,5 +1932,90 @@
"description": "Would you like to send the app to the system tray or quit?",
"minimize": "Minimize to Tray",
"quit": "Quit"
},
"tray": {
"show": "Show Donut Browser",
"quit": "Quit"
},
"browserSupport": {
"endingSoonTitle": "Browser support ending soon",
"endingSoonDescription": "Support for the following profiles will be removed on March 15, 2026: {{profiles}}. Please migrate to Wayfern or Camoufox profiles."
},
"onboarding": {
"steps": {
"createProfile": {
"title": "Create your first profile",
"content": "Click here to create your first profile. Pick Wayfern as the browser — the recommended, fingerprint-protected Chromium."
},
"dnsBlocking": {
"title": "DNS blocking",
"content": "Use this dropdown to set a DNS blocklist level for the profile — it blocks ads, trackers, and malware at the network level. Higher levels block more."
}
},
"buttons": {
"skip": "Skip",
"back": "Back",
"next": "Next",
"finish": "Finish"
},
"thankYou": {
"title": "Thank you for choosing Donut Browser",
"body": "Hopefully it helps make your browsing more private — every identity kept its own, and nothing leaving your machine. Enjoy.",
"cta": "Start browsing"
}
},
"welcome": {
"title": "Welcome to Donut Browser",
"tagline": "An open-source anti-detect browser for managing many identities at once.",
"skip": "Skip",
"next": "Next",
"permissions": {
"title": "Allow microphone & camera",
"desc": "Grant access so sites that need a mic or camera work inside your browser profiles. macOS asks once; each site still asks you individually.",
"skip": "Not now",
"grant": "Allow access",
"requesting": "Requesting…"
},
"ready": {
"title": "Setting things up",
"descDownloading": "Downloading your first browser (Wayfern). This one-time setup runs in the background — hang tight.",
"descReady": "Your browser is ready. Let's create your first profile.",
"cta": "Create my first profile",
"downloading": "Downloading…",
"extracting": "Extracting…",
"stats": "{{downloaded}} of {{total}}",
"speed": "{{speed}}/s",
"timeLeft": "{{time}} left",
"descExtracting": "Extracting your browser. This one-time setup runs in the background — hang tight.",
"almostFinished": "Almost finished…",
"errorTitle": "Setup failed",
"errorDownload": "{{browser}} couldn't be downloaded. Check your connection and try again.",
"errorExtraction": "{{browser}} couldn't be extracted. Please try again.",
"errorGeneric": "Something went wrong while setting up {{browser}}. Please try again.",
"retry": "Try again"
},
"features": {
"title": "Features",
"items": {
"setDefault": "Set as Default Browser",
"proxy": "Proxy Support (HTTP/SOCKS5)",
"vpn": "VPN Support (WireGuard)",
"profiles": "Unlimited Local Profiles",
"api": "Profile Management API & MCP",
"openSource": "Open Source",
"groups": "Profile Groups",
"cookies": "Cookie Import & Export"
}
},
"license": {
"title": "Licensing",
"body": "Donut Browser is open source and free to use.",
"agree": "I understand",
"personalTitle": "Personal use",
"personalDesc": "Free forever.",
"commercialTitle": "Commercial use",
"trialBadge": "2 weeks free",
"commercialDesc": "Free for a 2-week evaluation. After that, a paid plan keeps the project maintained and thriving."
}
}
}
+120 -21
View File
@@ -33,7 +33,8 @@
"minimize": "Minimizar",
"saving": "Guardando…",
"saved": "Guardado",
"copied": "Copiado"
"copied": "Copiado",
"learnMore": "Más información"
},
"status": {
"active": "Activo",
@@ -99,6 +100,9 @@
"srOnly": {
"copy": "Copiar",
"copied": "Copiado"
},
"placeholders": {
"example": "p. ej., {{value}}"
}
},
"settings": {
@@ -340,7 +344,8 @@
"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}})..."
"latestDownloading": "Descargando versión ({{version}})...",
"upgradeAvailable": "Hay una versión más reciente ({{version}}) de {{browser}} disponible."
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "Impulsado por Wayfern",
@@ -351,7 +356,9 @@
"passwordProtect": {
"label": "Proteger este perfil con contraseña",
"description": "Cifra los datos del perfil en disco. Necesario para abrirlo."
}
},
"downloadingSubtitle": "Descargando…",
"browsersDownloading": "Los navegadores aún se están descargando. La creación de perfiles estará disponible cuando termine una descarga."
},
"deleteDialog": {
"title": "Eliminar Perfil",
@@ -1188,7 +1195,9 @@
"cannotWhileRunning": "Detén el perfil antes de cambiar su contraseña."
},
"fingerprint": {
"notSupported": "La edición de huellas digitales solo está disponible para perfiles Camoufox y Wayfern."
"notSupported": "La edición de huellas digitales solo está disponible para perfiles Camoufox y Wayfern.",
"lockedTitle": "La huella digital es una función Pro",
"lockedDescription": "Ver y editar la huella digital de un perfil requiere un plan de pago activo. Mejora tu plan para desbloquear la protección de huella digital."
}
},
"extensions": {
@@ -1524,17 +1533,6 @@
"creatingButton": "Creando...",
"createButton": "Crear"
},
"launchOnLogin": {
"title": "¿Activar inicio al iniciar sesión?",
"description": "Ejecutarse en segundo plano ayuda a mantener vivos los proxies y navegadores.",
"declineButton": "No volver a preguntar",
"declining": "...",
"enableButton": "Activar",
"enableSuccess": "Inicio al iniciar sesión activado",
"enableFailed": "Error al activar el inicio al iniciar sesión",
"declineFailed": "Error al guardar la preferencia",
"tryAgain": "Por favor, inténtalo de nuevo"
},
"wayfernTerms": {
"title": "Términos y condiciones de Wayfern",
"description": "Antes de usar Donut Browser, debes leer y aceptar los Términos y Condiciones de Wayfern.",
@@ -1680,7 +1678,8 @@
"viewRelease": "Ver lanzamiento",
"later": "Más tarde",
"uploading": "Subiendo",
"downloading": "Descargando"
"downloading": "Descargando",
"startingUpdate": "Iniciando actualización..."
}
},
"browserDownload": {
@@ -1694,7 +1693,9 @@
"extractionFailedDescription": "El archivo dañado fue eliminado. Se volverá a descargar en el próximo intento.",
"extracting": "Extrayendo archivos del navegador... No cierre la aplicación.",
"verifying": "Verificando archivos del navegador...",
"downloadingRolling": "Descargando compilación rolling release..."
"downloadingRolling": "Descargando compilación rolling release...",
"geoipDownloading": "Descargando base de datos GeoIP",
"geoipDownloaded": "¡Base de datos GeoIP descargada correctamente!"
}
},
"versionUpdater": {
@@ -1712,7 +1713,12 @@
"updateSuccessDescription": "Se encontraron {{newVersions}} nuevas versiones en {{successfulUpdates}} navegadores. Las descargas automáticas comenzarán pronto.",
"upToDate": "No se encontraron nuevas versiones del navegador",
"upToDateDescription": "Todas las versiones del navegador están actualizadas",
"updateAllFailed": "Error al actualizar las versiones del navegador"
"updateAllFailed": "Error al actualizar las versiones del navegador",
"updateStarted": "Actualización de {{browser}} iniciada",
"updateStartedDescription": "La descarga de la versión {{version}} comenzará en breve. El inicio del navegador está deshabilitado hasta que finalice la actualización.",
"downloadStarting": "Iniciando la descarga de {{browser}} {{version}}",
"downloadProgressBelow": "El progreso de la descarga se mostrará a continuación...",
"autoDownloadStarted": "Descargando {{browser}} {{version}} automáticamente. El progreso se mostrará a continuación."
}
},
"profilePassword": {
@@ -1800,7 +1806,11 @@
"invalidLaunchHookUrl": "URL del hook de inicio no válida. Usa una URL completa http:// o https://.",
"cookieDbLocked": "No se pudieron leer las cookies — la base de datos está bloqueada. Cierra el navegador e inténtalo de nuevo.",
"cookieDbUnavailable": "No se pudieron leer las cookies — el almacén de cookies no está disponible.",
"selfHostedRequiresLogout": "Cierra sesión en tu cuenta de Donut antes de configurar un servidor autoalojado."
"selfHostedRequiresLogout": "Cierra sesión en tu cuenta de Donut antes de configurar un servidor autoalojado.",
"fingerprintRequiresPro": "La protección de huella digital requiere un plan de pago activo.",
"proxyNotWorking": "El proxy seleccionado no funciona, por lo que no se creó el perfil.",
"proxyPaymentRequired": "El proxy seleccionado requiere pago (402) —su suscripción puede haber vencido— por lo que no se creó el perfil.",
"vpnNotWorking": "La VPN seleccionada no funciona, por lo que no se creó el perfil."
},
"rail": {
"profiles": "Perfiles",
@@ -1866,7 +1876,8 @@
"plan": "Plan",
"status": "Estado",
"teamRole": "Rol en el equipo",
"period": "Período"
"period": "Período",
"device": "Dispositivo"
},
"tabs": {
"account": "Cuenta",
@@ -1880,7 +1891,10 @@
"statusUnknown": "Sin probar",
"testConnection": "Probar conexión",
"disconnect": "Desconectar"
}
},
"deviceOrdinal": "{{ordinal}} de {{count}}",
"automationPrimaryOnly": "La automatización del navegador solo funciona en tu dispositivo principal (Dispositivo 1). Cierra sesión allí para usarla aquí.",
"automationActiveHere": "La automatización del navegador está activa en este dispositivo."
},
"shortcutsPage": {
"title": "Atajos de teclado",
@@ -1918,5 +1932,90 @@
"description": "¿Quieres enviar la aplicación a la bandeja del sistema o salir?",
"minimize": "Minimizar a la bandeja",
"quit": "Salir"
},
"tray": {
"show": "Mostrar Donut Browser",
"quit": "Salir"
},
"browserSupport": {
"endingSoonTitle": "El soporte del navegador finalizará pronto",
"endingSoonDescription": "El soporte para los siguientes perfiles se eliminará el 15 de marzo de 2026: {{profiles}}. Migra a perfiles de Wayfern o Camoufox."
},
"onboarding": {
"steps": {
"createProfile": {
"title": "Crea tu primer perfil",
"content": "Haz clic aquí para crear tu primer perfil. Elige Wayfern como navegador: el Chromium recomendado y protegido contra huellas digitales."
},
"dnsBlocking": {
"title": "Bloqueo DNS",
"content": "Usa este menú para definir el nivel de la lista de bloqueo DNS del perfil: bloquea anuncios, rastreadores y malware a nivel de red. Los niveles más altos bloquean más."
}
},
"buttons": {
"skip": "Omitir",
"back": "Atrás",
"next": "Siguiente",
"finish": "Finalizar"
},
"thankYou": {
"title": "Gracias por elegir Donut Browser",
"body": "Ojalá ayude a hacer tu navegación más privada: cada identidad por separado y sin que nada salga de tu equipo. ¡Que lo disfrutes!",
"cta": "Empezar a navegar"
}
},
"welcome": {
"title": "Te damos la bienvenida a Donut Browser",
"tagline": "Un navegador antidetección de código abierto para gestionar muchas identidades a la vez.",
"skip": "Omitir",
"next": "Siguiente",
"permissions": {
"title": "Permitir micrófono y cámara",
"desc": "Concede acceso para que los sitios que necesitan micrófono o cámara funcionen en tus perfiles de navegador. macOS lo pregunta una vez; cada sitio te lo seguirá pidiendo por separado.",
"skip": "Ahora no",
"grant": "Permitir acceso",
"requesting": "Solicitando…"
},
"ready": {
"title": "Preparando todo",
"descDownloading": "Descargando tu primer navegador (Wayfern). Esta configuración única se ejecuta en segundo plano; espera un momento.",
"descReady": "Tu navegador está listo. Vamos a crear tu primer perfil.",
"cta": "Crear mi primer perfil",
"downloading": "Descargando…",
"extracting": "Extrayendo…",
"stats": "{{downloaded}} de {{total}}",
"speed": "{{speed}}/s",
"timeLeft": "{{time}} restante",
"descExtracting": "Extrayendo tu navegador. Esta configuración única se ejecuta en segundo plano: espera un momento.",
"almostFinished": "Casi terminado…",
"errorTitle": "Error en la configuración",
"errorDownload": "No se pudo descargar {{browser}}. Comprueba tu conexión e inténtalo de nuevo.",
"errorExtraction": "No se pudo extraer {{browser}}. Inténtalo de nuevo.",
"errorGeneric": "Algo salió mal al configurar {{browser}}. Inténtalo de nuevo.",
"retry": "Reintentar"
},
"features": {
"title": "Funciones",
"items": {
"setDefault": "Establecer como navegador predeterminado",
"proxy": "Compatibilidad con proxy (HTTP/SOCKS5)",
"vpn": "Compatibilidad con VPN (WireGuard)",
"profiles": "Perfiles locales ilimitados",
"api": "API de gestión de perfiles y MCP",
"openSource": "Código abierto",
"groups": "Grupos de perfiles",
"cookies": "Importar y exportar cookies"
}
},
"license": {
"title": "Licencias",
"body": "Donut Browser es de código abierto y de uso gratuito.",
"agree": "Entendido",
"personalTitle": "Uso personal",
"personalDesc": "Gratis para siempre.",
"commercialTitle": "Uso comercial",
"trialBadge": "2 semanas gratis",
"commercialDesc": "Gratis durante una evaluación de 2 semanas. Después, un plan de pago mantiene el proyecto en buen estado y próspero."
}
}
}
+120 -21
View File
@@ -33,7 +33,8 @@
"minimize": "Réduire",
"saving": "Enregistrement…",
"saved": "Enregistré",
"copied": "Copié"
"copied": "Copié",
"learnMore": "En savoir plus"
},
"status": {
"active": "Actif",
@@ -99,6 +100,9 @@
"srOnly": {
"copy": "Copier",
"copied": "Copié"
},
"placeholders": {
"example": "p. ex. {{value}}"
}
},
"settings": {
@@ -340,7 +344,8 @@
"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}})..."
"latestDownloading": "Téléchargement de la version ({{version}})...",
"upgradeAvailable": "Une version plus récente ({{version}}) de {{browser}} est disponible."
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "Propulsé par Wayfern",
@@ -351,7 +356,9 @@
"passwordProtect": {
"label": "Protéger ce profil par mot de passe",
"description": "Chiffre les données du profil sur disque. Requis au lancement."
}
},
"downloadingSubtitle": "Téléchargement…",
"browsersDownloading": "Les navigateurs sont encore en cours de téléchargement. La création de profils sera disponible une fois un téléchargement terminé."
},
"deleteDialog": {
"title": "Supprimer le profil",
@@ -1188,7 +1195,9 @@
"cannotWhileRunning": "Arrêtez le profil avant de modifier son mot de passe."
},
"fingerprint": {
"notSupported": "L’édition des empreintes nest disponible que pour les profils Camoufox et Wayfern."
"notSupported": "L’édition des empreintes nest disponible que pour les profils Camoufox et Wayfern.",
"lockedTitle": "L'empreinte est une fonctionnalité Pro",
"lockedDescription": "Afficher et modifier l'empreinte d'un profil nécessite un forfait payant actif. Passez à un forfait supérieur pour débloquer la protection contre le fingerprinting."
}
},
"extensions": {
@@ -1524,17 +1533,6 @@
"creatingButton": "Création...",
"createButton": "Créer"
},
"launchOnLogin": {
"title": "Activer le démarrage à la connexion ?",
"description": "Tourner en arrière-plan permet de garder vos proxys et navigateurs actifs.",
"declineButton": "Ne plus demander",
"declining": "...",
"enableButton": "Activer",
"enableSuccess": "Démarrage à la connexion activé",
"enableFailed": "Échec de l'activation du démarrage à la connexion",
"declineFailed": "Échec de l'enregistrement de la préférence",
"tryAgain": "Veuillez réessayer"
},
"wayfernTerms": {
"title": "Conditions générales de Wayfern",
"description": "Avant d'utiliser Donut Browser, vous devez lire et accepter les Conditions Générales de Wayfern.",
@@ -1680,7 +1678,8 @@
"viewRelease": "Voir la version",
"later": "Plus tard",
"uploading": "Envoi",
"downloading": "Téléchargement"
"downloading": "Téléchargement",
"startingUpdate": "Démarrage de la mise à jour..."
}
},
"browserDownload": {
@@ -1694,7 +1693,9 @@
"extractionFailedDescription": "Le fichier corrompu a été supprimé. Il sera retéléchargé lors de la prochaine tentative.",
"extracting": "Extraction des fichiers du navigateur... Ne fermez pas l'application.",
"verifying": "Vérification des fichiers du navigateur...",
"downloadingRolling": "Téléchargement de la version rolling release..."
"downloadingRolling": "Téléchargement de la version rolling release...",
"geoipDownloading": "Téléchargement de la base de données GeoIP",
"geoipDownloaded": "Base de données GeoIP téléchargée avec succès !"
}
},
"versionUpdater": {
@@ -1712,7 +1713,12 @@
"updateSuccessDescription": "{{newVersions}} nouvelles versions trouvées sur {{successfulUpdates}} navigateurs. Les téléchargements automatiques commenceront sous peu.",
"upToDate": "Aucune nouvelle version de navigateur trouvée",
"upToDateDescription": "Toutes les versions des navigateurs sont à jour",
"updateAllFailed": "Échec de la mise à jour des versions des navigateurs"
"updateAllFailed": "Échec de la mise à jour des versions des navigateurs",
"updateStarted": "Mise à jour de {{browser}} démarrée",
"updateStartedDescription": "Le téléchargement de la version {{version}} va bientôt commencer. Le lancement du navigateur est désactivé jusqu'à la fin de la mise à jour.",
"downloadStarting": "Démarrage du téléchargement de {{browser}} {{version}}",
"downloadProgressBelow": "La progression du téléchargement sera affichée ci-dessous...",
"autoDownloadStarted": "Téléchargement automatique de {{browser}} {{version}}. La progression sera affichée ci-dessous."
}
},
"profilePassword": {
@@ -1800,7 +1806,11 @@
"invalidLaunchHookUrl": "URL du hook de lancement invalide. Utilisez une URL http:// ou https:// complète.",
"cookieDbLocked": "Impossible de lire les cookies — la base de données est verrouillée. Fermez le navigateur et réessayez.",
"cookieDbUnavailable": "Impossible de lire les cookies — le magasin de cookies est indisponible.",
"selfHostedRequiresLogout": "Déconnectez-vous de votre compte Donut avant de configurer un serveur auto-hébergé."
"selfHostedRequiresLogout": "Déconnectez-vous de votre compte Donut avant de configurer un serveur auto-hébergé.",
"fingerprintRequiresPro": "La protection contre le fingerprinting nécessite un forfait payant actif.",
"proxyNotWorking": "Le proxy sélectionné ne fonctionne pas, le profil n'a donc pas été créé.",
"proxyPaymentRequired": "Le proxy sélectionné requiert un paiement (402) — son abonnement a peut-être expiré — le profil n'a donc pas été créé.",
"vpnNotWorking": "Le VPN sélectionné ne fonctionne pas, le profil n'a donc pas été créé."
},
"rail": {
"profiles": "Profils",
@@ -1866,7 +1876,8 @@
"plan": "Plan",
"status": "Statut",
"teamRole": "Rôle d’équipe",
"period": "Période"
"period": "Période",
"device": "Appareil"
},
"tabs": {
"account": "Compte",
@@ -1880,7 +1891,10 @@
"statusUnknown": "Non testé",
"testConnection": "Tester la connexion",
"disconnect": "Déconnecter"
}
},
"deviceOrdinal": "{{ordinal}} sur {{count}}",
"automationPrimaryOnly": "L'automatisation du navigateur ne fonctionne que sur votre appareil principal (Appareil 1). Déconnectez-vous là-bas pour l'utiliser ici.",
"automationActiveHere": "L'automatisation du navigateur est active sur cet appareil."
},
"shortcutsPage": {
"title": "Raccourcis clavier",
@@ -1918,5 +1932,90 @@
"description": "Voulez-vous envoyer l'application dans la zone de notification ou quitter ?",
"minimize": "Réduire dans la barre d'état",
"quit": "Quitter"
},
"tray": {
"show": "Afficher Donut Browser",
"quit": "Quitter"
},
"browserSupport": {
"endingSoonTitle": "La prise en charge du navigateur prend bientôt fin",
"endingSoonDescription": "La prise en charge des profils suivants sera supprimée le 15 mars 2026 : {{profiles}}. Veuillez migrer vers des profils Wayfern ou Camoufox."
},
"onboarding": {
"steps": {
"createProfile": {
"title": "Créez votre premier profil",
"content": "Cliquez ici pour créer votre premier profil. Choisissez Wayfern comme navigateur : le Chromium recommandé et protégé contre le fingerprinting."
},
"dnsBlocking": {
"title": "Blocage DNS",
"content": "Utilisez ce menu pour définir le niveau de la liste de blocage DNS du profil : il bloque les publicités, les traqueurs et les logiciels malveillants au niveau du réseau. Les niveaux supérieurs bloquent davantage."
}
},
"buttons": {
"skip": "Passer",
"back": "Retour",
"next": "Suivant",
"finish": "Terminer"
},
"thankYou": {
"title": "Merci d'avoir choisi Donut Browser",
"body": "Avec un peu de chance, il rendra votre navigation plus privée : chaque identité séparée et rien ne quittant votre machine. Bonne navigation !",
"cta": "Commencer à naviguer"
}
},
"welcome": {
"title": "Bienvenue dans Donut Browser",
"tagline": "Un navigateur anti-détection open source pour gérer de nombreuses identités à la fois.",
"skip": "Passer",
"next": "Suivant",
"permissions": {
"title": "Autoriser le micro et la caméra",
"desc": "Accordez l'accès pour que les sites nécessitant un micro ou une caméra fonctionnent dans vos profils de navigateur. macOS le demande une fois ; chaque site vous le demandera quand même individuellement.",
"skip": "Plus tard",
"grant": "Autoriser l'accès",
"requesting": "Demande en cours…"
},
"ready": {
"title": "Préparation en cours",
"descDownloading": "Téléchargement de votre premier navigateur (Wayfern). Cette configuration unique s'exécute en arrière-plan — patientez un instant.",
"descReady": "Votre navigateur est prêt. Créons votre premier profil.",
"cta": "Créer mon premier profil",
"downloading": "Téléchargement…",
"extracting": "Extraction…",
"stats": "{{downloaded}} sur {{total}}",
"speed": "{{speed}}/s",
"timeLeft": "{{time}} restant",
"descExtracting": "Extraction de votre navigateur. Cette configuration unique s'exécute en arrière-plan, patientez.",
"almostFinished": "Presque terminé…",
"errorTitle": "Échec de la configuration",
"errorDownload": "Impossible de télécharger {{browser}}. Vérifiez votre connexion et réessayez.",
"errorExtraction": "Impossible d'extraire {{browser}}. Veuillez réessayer.",
"errorGeneric": "Une erreur s'est produite lors de la configuration de {{browser}}. Veuillez réessayer.",
"retry": "Réessayer"
},
"features": {
"title": "Fonctionnalités",
"items": {
"setDefault": "Définir comme navigateur par défaut",
"proxy": "Prise en charge des proxys (HTTP/SOCKS5)",
"vpn": "Prise en charge du VPN (WireGuard)",
"profiles": "Profils locaux illimités",
"api": "API de gestion des profils et MCP",
"openSource": "Open source",
"groups": "Groupes de profils",
"cookies": "Import et export de cookies"
}
},
"license": {
"title": "Licence",
"body": "Donut Browser est open source et gratuit.",
"agree": "J'ai compris",
"personalTitle": "Usage personnel",
"personalDesc": "Gratuit à vie.",
"commercialTitle": "Usage commercial",
"trialBadge": "2 semaines gratuites",
"commercialDesc": "Gratuit pendant une évaluation de 2 semaines. Ensuite, un forfait payant permet de maintenir et de faire prospérer le projet."
}
}
}
+120 -21
View File
@@ -33,7 +33,8 @@
"minimize": "最小化",
"saving": "保存中…",
"saved": "保存しました",
"copied": "コピーしました"
"copied": "コピーしました",
"learnMore": "詳細"
},
"status": {
"active": "アクティブ",
@@ -99,6 +100,9 @@
"srOnly": {
"copy": "コピー",
"copied": "コピーしました"
},
"placeholders": {
"example": "例: {{value}}"
}
},
"settings": {
@@ -340,7 +344,8 @@
"downloading": "{{browser}} バージョン ({{version}}) をダウンロード中...",
"latestNeedsDownload": "最新バージョン ({{version}}) をダウンロードする必要があります",
"latestAvailable": "最新バージョン ({{version}}) は利用可能です",
"latestDownloading": "バージョン ({{version}}) をダウンロード中..."
"latestDownloading": "バージョン ({{version}}) をダウンロード中...",
"upgradeAvailable": "{{browser}} の新しいバージョン({{version}})が利用可能です。"
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "Wayfern搭載",
@@ -351,7 +356,9 @@
"passwordProtect": {
"label": "このプロファイルをパスワードで保護",
"description": "ディスク上のプロファイルデータを暗号化します。起動に必要です。"
}
},
"downloadingSubtitle": "ダウンロード中…",
"browsersDownloading": "ブラウザをダウンロード中です。ダウンロードが完了するとプロファイルを作成できます。"
},
"deleteDialog": {
"title": "プロファイルを削除",
@@ -1188,7 +1195,9 @@
"cannotWhileRunning": "パスワードを変更する前にプロファイルを停止してください。"
},
"fingerprint": {
"notSupported": "フィンガープリント編集は Camoufox / Wayfern プロファイルでのみ利用できます。"
"notSupported": "フィンガープリント編集は Camoufox / Wayfern プロファイルでのみ利用できます。",
"lockedTitle": "フィンガープリントは Pro 機能です",
"lockedDescription": "プロファイルのフィンガープリントの表示と編集には有効な有料プランが必要です。アップグレードしてフィンガープリント保護をご利用ください。"
}
},
"extensions": {
@@ -1524,17 +1533,6 @@
"creatingButton": "作成中...",
"createButton": "作成"
},
"launchOnLogin": {
"title": "ログイン時に起動しますか?",
"description": "バックグラウンドで実行することで、プロキシとブラウザを維持できます。",
"declineButton": "今後は表示しない",
"declining": "...",
"enableButton": "有効にする",
"enableSuccess": "ログイン時の起動を有効にしました",
"enableFailed": "ログイン時の起動を有効にできませんでした",
"declineFailed": "設定の保存に失敗しました",
"tryAgain": "もう一度お試しください"
},
"wayfernTerms": {
"title": "Wayfern 利用規約",
"description": "Donut Browser を使用する前に、Wayfern の利用規約を読み、同意する必要があります。",
@@ -1680,7 +1678,8 @@
"viewRelease": "リリースを見る",
"later": "後で",
"uploading": "アップロード中",
"downloading": "ダウンロード中"
"downloading": "ダウンロード中",
"startingUpdate": "更新を開始しています..."
}
},
"browserDownload": {
@@ -1694,7 +1693,9 @@
"extractionFailedDescription": "破損したファイルは削除されました。次回の試行時に再ダウンロードされます。",
"extracting": "ブラウザファイルを展開中... アプリを閉じないでください。",
"verifying": "ブラウザファイルを検証中...",
"downloadingRolling": "ローリングリリースビルドをダウンロード中..."
"downloadingRolling": "ローリングリリースビルドをダウンロード中...",
"geoipDownloading": "GeoIP データベースをダウンロード中",
"geoipDownloaded": "GeoIP データベースのダウンロードが完了しました!"
}
},
"versionUpdater": {
@@ -1712,7 +1713,12 @@
"updateSuccessDescription": "{{successfulUpdates}} 個のブラウザに {{newVersions}} 個の新しいバージョンが見つかりました。自動ダウンロードがまもなく開始します。",
"upToDate": "新しいブラウザのバージョンは見つかりませんでした",
"upToDateDescription": "すべてのブラウザバージョンは最新です",
"updateAllFailed": "ブラウザバージョンの更新に失敗しました"
"updateAllFailed": "ブラウザバージョンの更新に失敗しました",
"updateStarted": "{{browser}} の更新を開始しました",
"updateStartedDescription": "バージョン {{version}} のダウンロードがまもなく開始されます。更新が完了するまでブラウザの起動は無効になります。",
"downloadStarting": "{{browser}} {{version}} のダウンロードを開始しています",
"downloadProgressBelow": "ダウンロードの進行状況は下に表示されます...",
"autoDownloadStarted": "{{browser}} {{version}} を自動的にダウンロードしています。進行状況は下に表示されます。"
}
},
"profilePassword": {
@@ -1800,7 +1806,11 @@
"invalidLaunchHookUrl": "起動フックURLが無効です。完全な http:// または https:// URL を使用してください。",
"cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。",
"cookieDbUnavailable": "Cookie を読み取れません — Cookie ストアを利用できません。",
"selfHostedRequiresLogout": "セルフホストサーバーを設定する前に Donut アカウントからサインアウトしてください。"
"selfHostedRequiresLogout": "セルフホストサーバーを設定する前に Donut アカウントからサインアウトしてください。",
"fingerprintRequiresPro": "フィンガープリント保護には有効な有料プランが必要です。",
"proxyNotWorking": "選択したプロキシが機能していないため、プロファイルは作成されませんでした。",
"proxyPaymentRequired": "選択したプロキシは支払いが必要です(402)。サブスクリプションが期限切れの可能性があります。そのため、プロファイルは作成されませんでした。",
"vpnNotWorking": "選択したVPNが機能していないため、プロファイルは作成されませんでした。"
},
"rail": {
"profiles": "プロファイル",
@@ -1866,7 +1876,8 @@
"plan": "プラン",
"status": "ステータス",
"teamRole": "チームロール",
"period": "請求周期"
"period": "請求周期",
"device": "デバイス"
},
"tabs": {
"account": "アカウント",
@@ -1880,7 +1891,10 @@
"statusUnknown": "未テスト",
"testConnection": "接続をテスト",
"disconnect": "切断"
}
},
"deviceOrdinal": "{{count}} 台中 {{ordinal}} 台目",
"automationPrimaryOnly": "ブラウザの自動化はプライマリデバイス(デバイス1)でのみ実行できます。ここで使用するには、そのデバイスでサインアウトしてください。",
"automationActiveHere": "ブラウザの自動化はこのデバイスで有効です。"
},
"shortcutsPage": {
"title": "キーボードショートカット",
@@ -1918,5 +1932,90 @@
"description": "アプリをシステムトレイに格納しますか、それとも終了しますか?",
"minimize": "トレイに格納",
"quit": "終了"
},
"tray": {
"show": "Donut Browser を表示",
"quit": "終了"
},
"browserSupport": {
"endingSoonTitle": "ブラウザのサポートが間もなく終了します",
"endingSoonDescription": "次のプロファイルのサポートは 2026 年 3 月 15 日に削除されます: {{profiles}}。Wayfern または Camoufox のプロファイルに移行してください。"
},
"onboarding": {
"steps": {
"createProfile": {
"title": "最初のプロファイルを作成",
"content": "ここをクリックして最初のプロファイルを作成します。ブラウザには Wayfern を選んでください。フィンガープリント対策済みの推奨 Chromium です。"
},
"dnsBlocking": {
"title": "DNS ブロック",
"content": "このドロップダウンでプロファイルの DNS ブロックリストのレベルを設定します。広告・トラッカー・マルウェアをネットワークレベルでブロックします。レベルが高いほど多くブロックします。"
}
},
"buttons": {
"skip": "スキップ",
"back": "戻る",
"next": "次へ",
"finish": "完了"
},
"thankYou": {
"title": "Donut Browser を選んでいただきありがとうございます",
"body": "それぞれのIDを分けて、データを端末の外に出さずに、よりプライベートなブラウジングのお役に立てれば幸いです。どうぞお楽しみください。",
"cta": "ブラウジングを始める"
}
},
"welcome": {
"title": "Donut Browser へようこそ",
"tagline": "複数のIDを同時に管理できるオープンソースのアンチディテクトブラウザ。",
"skip": "スキップ",
"next": "次へ",
"permissions": {
"title": "マイクとカメラを許可",
"desc": "マイクやカメラを必要とするサイトがブラウザプロファイル内で動作するよう、アクセスを許可してください。macOS は一度だけ確認します。各サイトは引き続き個別に許可を求めます。",
"skip": "後で",
"grant": "アクセスを許可",
"requesting": "リクエスト中…"
},
"ready": {
"title": "準備しています",
"descDownloading": "最初のブラウザ(Wayfern)をダウンロードしています。この初回セットアップはバックグラウンドで実行されます。少々お待ちください。",
"descReady": "ブラウザの準備ができました。最初のプロファイルを作成しましょう。",
"cta": "最初のプロファイルを作成",
"downloading": "ダウンロード中…",
"extracting": "展開中…",
"stats": "{{total}} 中 {{downloaded}}",
"speed": "{{speed}}/秒",
"timeLeft": "残り {{time}}",
"descExtracting": "ブラウザを展開しています。この初回セットアップはバックグラウンドで実行されます。少々お待ちください。",
"almostFinished": "まもなく完了します…",
"errorTitle": "セットアップに失敗しました",
"errorDownload": "{{browser}} をダウンロードできませんでした。接続を確認して、もう一度お試しください。",
"errorExtraction": "{{browser}} を展開できませんでした。もう一度お試しください。",
"errorGeneric": "{{browser}} のセットアップ中に問題が発生しました。もう一度お試しください。",
"retry": "再試行"
},
"features": {
"title": "機能",
"items": {
"setDefault": "既定のブラウザに設定",
"proxy": "プロキシ対応(HTTP/SOCKS5",
"vpn": "VPN対応(WireGuard",
"profiles": "無制限のローカルプロファイル",
"api": "プロファイル管理APIとMCP",
"openSource": "オープンソース",
"groups": "プロファイルグループ",
"cookies": "Cookieのインポート・エクスポート"
}
},
"license": {
"title": "ライセンス",
"body": "Donut Browser はオープンソースで、無料で利用できます。",
"agree": "了解しました",
"personalTitle": "個人利用",
"personalDesc": "永久に無料です。",
"commercialTitle": "商用利用",
"trialBadge": "2週間無料",
"commercialDesc": "2週間の評価期間は無料です。その後は有料プランが必要で、これによりプロジェクトの維持と発展が支えられます。"
}
}
}
+120 -21
View File
@@ -33,7 +33,8 @@
"minimize": "최소화",
"saving": "저장 중…",
"saved": "저장됨",
"copied": "복사됨"
"copied": "복사됨",
"learnMore": "자세히 알아보기"
},
"status": {
"active": "활성",
@@ -99,6 +100,9 @@
"srOnly": {
"copy": "복사",
"copied": "복사됨"
},
"placeholders": {
"example": "예: {{value}}"
}
},
"settings": {
@@ -340,7 +344,8 @@
"downloading": "{{browser}} 버전 ({{version}})을 다운로드하는 중...",
"latestNeedsDownload": "최신 버전 ({{version}})을 다운로드해야 합니다",
"latestAvailable": "최신 버전 ({{version}})을 사용할 수 있습니다",
"latestDownloading": "버전 ({{version}})을 다운로드하는 중..."
"latestDownloading": "버전 ({{version}})을 다운로드하는 중...",
"upgradeAvailable": "{{browser}}의 최신 버전({{version}})을 사용할 수 있습니다."
},
"chromiumLabel": "크로미움",
"chromiumSubtitle": "Wayfern 기반",
@@ -351,7 +356,9 @@
"passwordProtect": {
"label": "이 프로필을 비밀번호로 보호",
"description": "디스크에 저장된 프로필 데이터를 암호화합니다. 실행하려면 필요합니다."
}
},
"downloadingSubtitle": "다운로드 중…",
"browsersDownloading": "브라우저를 아직 다운로드하는 중입니다. 다운로드가 완료되면 프로필을 만들 수 있습니다."
},
"deleteDialog": {
"title": "프로필 삭제",
@@ -1188,7 +1195,9 @@
"cannotWhileRunning": "비밀번호를 변경하기 전에 프로필을 중지하세요."
},
"fingerprint": {
"notSupported": "핑거프린트 편집은 Camoufox 및 Wayfern 프로필에서만 사용할 수 있습니다."
"notSupported": "핑거프린트 편집은 Camoufox 및 Wayfern 프로필에서만 사용할 수 있습니다.",
"lockedTitle": "핑거프린트는 Pro 기능입니다",
"lockedDescription": "프로필의 핑거프린트를 보고 편집하려면 활성 유료 요금제가 필요합니다. 업그레이드하여 핑거프린트 보호를 잠금 해제하세요."
}
},
"extensions": {
@@ -1524,17 +1533,6 @@
"creatingButton": "생성 중...",
"createButton": "생성"
},
"launchOnLogin": {
"title": "로그인 시 실행을 활성화하시겠습니까?",
"description": "백그라운드에서 실행하면 프록시와 브라우저를 계속 유지할 수 있습니다.",
"declineButton": "다시 묻지 않음",
"declining": "...",
"enableButton": "활성화",
"enableSuccess": "로그인 시 실행이 활성화되었습니다",
"enableFailed": "로그인 시 실행 활성화 실패",
"declineFailed": "환경 설정 저장 실패",
"tryAgain": "다시 시도하세요"
},
"wayfernTerms": {
"title": "Wayfern 이용 약관",
"description": "도넛 브라우저를 사용하기 전에 Wayfern의 이용 약관을 읽고 동의해야 합니다.",
@@ -1680,7 +1678,8 @@
"viewRelease": "릴리스 보기",
"later": "나중에",
"uploading": "업로드 중",
"downloading": "다운로드 중"
"downloading": "다운로드 중",
"startingUpdate": "업데이트를 시작하는 중..."
}
},
"browserDownload": {
@@ -1694,7 +1693,9 @@
"extractionFailedDescription": "손상된 파일이 삭제되었습니다. 다음 시도 시 다시 다운로드됩니다.",
"extracting": "브라우저 파일 압축 해제 중... 앱을 닫지 마세요.",
"verifying": "브라우저 파일 확인 중...",
"downloadingRolling": "롤링 릴리스 빌드 다운로드 중..."
"downloadingRolling": "롤링 릴리스 빌드 다운로드 중...",
"geoipDownloading": "GeoIP 데이터베이스 다운로드 중",
"geoipDownloaded": "GeoIP 데이터베이스를 성공적으로 다운로드했습니다!"
}
},
"versionUpdater": {
@@ -1712,7 +1713,12 @@
"updateSuccessDescription": "{{successfulUpdates}}개 브라우저에서 {{newVersions}}개의 새 버전을 찾았습니다. 자동 다운로드가 곧 시작됩니다.",
"upToDate": "새 브라우저 버전이 없습니다",
"upToDateDescription": "모든 브라우저 버전이 최신입니다",
"updateAllFailed": "브라우저 버전 업데이트 실패"
"updateAllFailed": "브라우저 버전 업데이트 실패",
"updateStarted": "{{browser}} 업데이트를 시작했습니다",
"updateStartedDescription": "버전 {{version}} 다운로드가 곧 시작됩니다. 업데이트가 완료될 때까지 브라우저 실행이 비활성화됩니다.",
"downloadStarting": "{{browser}} {{version}} 다운로드를 시작하는 중",
"downloadProgressBelow": "다운로드 진행 상황이 아래에 표시됩니다...",
"autoDownloadStarted": "{{browser}} {{version}}을(를) 자동으로 다운로드하는 중입니다. 진행 상황이 아래에 표시됩니다."
}
},
"profilePassword": {
@@ -1800,7 +1806,11 @@
"invalidLaunchHookUrl": "잘못된 실행 후크 URL입니다. 전체 http:// 또는 https:// URL을 사용하세요.",
"cookieDbLocked": "쿠키를 읽을 수 없습니다 — 데이터베이스가 잠겨 있습니다. 브라우저를 닫고 다시 시도하세요.",
"cookieDbUnavailable": "쿠키를 읽을 수 없습니다 — 쿠키 저장소를 사용할 수 없습니다.",
"selfHostedRequiresLogout": "자체 호스팅 서버를 구성하기 전에 도넛 계정에서 로그아웃하세요."
"selfHostedRequiresLogout": "자체 호스팅 서버를 구성하기 전에 도넛 계정에서 로그아웃하세요.",
"fingerprintRequiresPro": "핑거프린트 보호에는 활성 유료 요금제가 필요합니다.",
"proxyNotWorking": "선택한 프록시가 작동하지 않아 프로필이 생성되지 않았습니다.",
"proxyPaymentRequired": "선택한 프록시는 결제가 필요합니다(402). 구독이 만료되었을 수 있어 프로필이 생성되지 않았습니다.",
"vpnNotWorking": "선택한 VPN이 작동하지 않아 프로필이 생성되지 않았습니다."
},
"rail": {
"profiles": "프로필",
@@ -1866,7 +1876,8 @@
"plan": "플랜",
"status": "상태",
"teamRole": "팀 역할",
"period": "결제 기간"
"period": "결제 기간",
"device": "기기"
},
"tabs": {
"account": "계정",
@@ -1880,7 +1891,10 @@
"statusUnknown": "테스트 안 됨",
"testConnection": "연결 테스트",
"disconnect": "연결 해제"
}
},
"deviceOrdinal": "{{count}}대 중 {{ordinal}}번째",
"automationPrimaryOnly": "브라우저 자동화는 기본 기기(기기 1)에서만 실행됩니다. 여기서 사용하려면 해당 기기에서 로그아웃하세요.",
"automationActiveHere": "이 기기에서 브라우저 자동화가 활성화되어 있습니다."
},
"shortcutsPage": {
"title": "키보드 단축키",
@@ -1918,5 +1932,90 @@
"description": "앱을 시스템 트레이로 보내시겠습니까, 아니면 종료하시겠습니까?",
"minimize": "트레이로 최소화",
"quit": "종료"
},
"tray": {
"show": "Donut Browser 표시",
"quit": "종료"
},
"browserSupport": {
"endingSoonTitle": "브라우저 지원이 곧 종료됩니다",
"endingSoonDescription": "다음 프로필에 대한 지원이 2026년 3월 15일에 제거됩니다: {{profiles}}. Wayfern 또는 Camoufox 프로필로 마이그레이션하세요."
},
"onboarding": {
"steps": {
"createProfile": {
"title": "첫 프로필 만들기",
"content": "여기를 클릭하여 첫 프로필을 만드세요. 브라우저로는 Wayfern을 선택하세요. 핑거프린트 방지가 적용된 권장 Chromium입니다."
},
"dnsBlocking": {
"title": "DNS 차단",
"content": "이 드롭다운으로 프로필의 DNS 차단 목록 수준을 설정하세요. 광고, 추적기, 멀웨어를 네트워크 수준에서 차단합니다. 수준이 높을수록 더 많이 차단합니다."
}
},
"buttons": {
"skip": "건너뛰기",
"back": "뒤로",
"next": "다음",
"finish": "완료"
},
"thankYou": {
"title": "Donut Browser를 선택해 주셔서 감사합니다",
"body": "각 ID를 분리하고 어떤 데이터도 기기 밖으로 내보내지 않으면서 더 프라이빗한 브라우징에 도움이 되길 바랍니다. 즐겁게 사용하세요.",
"cta": "브라우징 시작"
}
},
"welcome": {
"title": "Donut Browser에 오신 것을 환영합니다",
"tagline": "여러 ID를 한 번에 관리할 수 있는 오픈 소스 안티디텍트 브라우저입니다.",
"skip": "건너뛰기",
"next": "다음",
"permissions": {
"title": "마이크 및 카메라 허용",
"desc": "마이크나 카메라가 필요한 사이트가 브라우저 프로필 안에서 작동하도록 액세스를 허용하세요. macOS는 한 번만 묻고, 각 사이트는 여전히 개별적으로 요청합니다.",
"skip": "나중에",
"grant": "액세스 허용",
"requesting": "요청 중…"
},
"ready": {
"title": "준비 중입니다",
"descDownloading": "첫 브라우저(Wayfern)를 다운로드하고 있습니다. 이 일회성 설정은 백그라운드에서 실행됩니다 — 잠시만 기다려 주세요.",
"descReady": "브라우저가 준비되었습니다. 첫 프로필을 만들어 봅시다.",
"cta": "첫 프로필 만들기",
"downloading": "다운로드 중…",
"extracting": "압축 푸는 중…",
"stats": "{{total}} 중 {{downloaded}}",
"speed": "{{speed}}/초",
"timeLeft": "{{time}} 남음",
"descExtracting": "브라우저를 추출하는 중입니다. 이 일회성 설정은 백그라운드에서 실행됩니다. 잠시만 기다려 주세요.",
"almostFinished": "거의 완료되었습니다…",
"errorTitle": "설정 실패",
"errorDownload": "{{browser}}을(를) 다운로드하지 못했습니다. 연결을 확인하고 다시 시도하세요.",
"errorExtraction": "{{browser}}을(를) 추출하지 못했습니다. 다시 시도하세요.",
"errorGeneric": "{{browser}} 설정 중 문제가 발생했습니다. 다시 시도하세요.",
"retry": "다시 시도"
},
"features": {
"title": "기능",
"items": {
"setDefault": "기본 브라우저로 설정",
"proxy": "프록시 지원 (HTTP/SOCKS5)",
"vpn": "VPN 지원 (WireGuard)",
"profiles": "무제한 로컬 프로필",
"api": "프로필 관리 API 및 MCP",
"openSource": "오픈 소스",
"groups": "프로필 그룹",
"cookies": "쿠키 가져오기 및 내보내기"
}
},
"license": {
"title": "라이선스",
"body": "Donut Browser는 오픈 소스이며 무료로 사용할 수 있습니다.",
"agree": "이해했습니다",
"personalTitle": "개인 사용",
"personalDesc": "영구적으로 무료입니다.",
"commercialTitle": "상업적 사용",
"trialBadge": "2주 무료",
"commercialDesc": "2주간의 평가 기간 동안 무료입니다. 이후에는 유료 요금제가 필요하며, 이를 통해 프로젝트가 유지되고 발전할 수 있습니다."
}
}
}
+120 -21
View File
@@ -33,7 +33,8 @@
"minimize": "Minimizar",
"saving": "Salvando…",
"saved": "Salvo",
"copied": "Copiado"
"copied": "Copiado",
"learnMore": "Saiba mais"
},
"status": {
"active": "Ativo",
@@ -99,6 +100,9 @@
"srOnly": {
"copy": "Copiar",
"copied": "Copiado"
},
"placeholders": {
"example": "ex.: {{value}}"
}
},
"settings": {
@@ -340,7 +344,8 @@
"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}})..."
"latestDownloading": "Baixando versão ({{version}})...",
"upgradeAvailable": "Uma versão mais recente ({{version}}) do {{browser}} está disponível."
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "Desenvolvido com Wayfern",
@@ -351,7 +356,9 @@
"passwordProtect": {
"label": "Proteger este perfil com senha",
"description": "Criptografa os dados do perfil em disco. Necessário para iniciar."
}
},
"downloadingSubtitle": "Baixando…",
"browsersDownloading": "Os navegadores ainda estão sendo baixados. A criação de perfis ficará disponível assim que um download terminar."
},
"deleteDialog": {
"title": "Excluir Perfil",
@@ -1188,7 +1195,9 @@
"cannotWhileRunning": "Pare o perfil antes de alterar a senha."
},
"fingerprint": {
"notSupported": "A edição de impressão digital só está disponível para perfis Camoufox e Wayfern."
"notSupported": "A edição de impressão digital só está disponível para perfis Camoufox e Wayfern.",
"lockedTitle": "A impressão digital é um recurso Pro",
"lockedDescription": "Visualizar e editar a impressão digital de um perfil requer um plano pago ativo. Faça upgrade para desbloquear a proteção contra fingerprint."
}
},
"extensions": {
@@ -1524,17 +1533,6 @@
"creatingButton": "Criando...",
"createButton": "Criar"
},
"launchOnLogin": {
"title": "Ativar inicialização no login?",
"description": "Rodar em segundo plano ajuda a manter seus proxies e navegadores ativos.",
"declineButton": "Não perguntar novamente",
"declining": "...",
"enableButton": "Ativar",
"enableSuccess": "Inicialização no login ativada",
"enableFailed": "Falha ao ativar a inicialização no login",
"declineFailed": "Falha ao salvar a preferência",
"tryAgain": "Tente novamente"
},
"wayfernTerms": {
"title": "Termos e condições da Wayfern",
"description": "Antes de usar o Donut Browser, você deve ler e concordar com os Termos e Condições da Wayfern.",
@@ -1680,7 +1678,8 @@
"viewRelease": "Ver lançamento",
"later": "Mais tarde",
"uploading": "Enviando",
"downloading": "Baixando"
"downloading": "Baixando",
"startingUpdate": "Iniciando atualização..."
}
},
"browserDownload": {
@@ -1694,7 +1693,9 @@
"extractionFailedDescription": "O arquivo corrompido foi excluído. Será baixado novamente na próxima tentativa.",
"extracting": "Extraindo arquivos do navegador... Não feche o aplicativo.",
"verifying": "Verificando arquivos do navegador...",
"downloadingRolling": "Baixando build rolling release..."
"downloadingRolling": "Baixando build rolling release...",
"geoipDownloading": "Baixando banco de dados GeoIP",
"geoipDownloaded": "Banco de dados GeoIP baixado com sucesso!"
}
},
"versionUpdater": {
@@ -1712,7 +1713,12 @@
"updateSuccessDescription": "Foram encontradas {{newVersions}} novas versões em {{successfulUpdates}} navegadores. Os downloads automáticos começarão em breve.",
"upToDate": "Nenhuma nova versão de navegador encontrada",
"upToDateDescription": "Todas as versões dos navegadores estão atualizadas",
"updateAllFailed": "Falha ao atualizar as versões dos navegadores"
"updateAllFailed": "Falha ao atualizar as versões dos navegadores",
"updateStarted": "Atualização do {{browser}} iniciada",
"updateStartedDescription": "O download da versão {{version}} começará em breve. O início do navegador está desativado até a atualização ser concluída.",
"downloadStarting": "Iniciando o download do {{browser}} {{version}}",
"downloadProgressBelow": "O progresso do download será mostrado abaixo...",
"autoDownloadStarted": "Baixando {{browser}} {{version}} automaticamente. O progresso será mostrado abaixo."
}
},
"profilePassword": {
@@ -1800,7 +1806,11 @@
"invalidLaunchHookUrl": "URL do hook de inicialização inválida. Use uma URL completa http:// ou https://.",
"cookieDbLocked": "Não foi possível ler os cookies — o banco de dados está bloqueado. Feche o navegador e tente novamente.",
"cookieDbUnavailable": "Não foi possível ler os cookies — o repositório de cookies está indisponível.",
"selfHostedRequiresLogout": "Saia da sua conta Donut antes de configurar um servidor auto-hospedado."
"selfHostedRequiresLogout": "Saia da sua conta Donut antes de configurar um servidor auto-hospedado.",
"fingerprintRequiresPro": "A proteção contra fingerprint requer um plano pago ativo.",
"proxyNotWorking": "O proxy selecionado não está funcionando, então o perfil não foi criado.",
"proxyPaymentRequired": "O proxy selecionado exige pagamento (402) — sua assinatura pode ter expirado — então o perfil não foi criado.",
"vpnNotWorking": "A VPN selecionada não está funcionando, então o perfil não foi criado."
},
"rail": {
"profiles": "Perfis",
@@ -1866,7 +1876,8 @@
"plan": "Plano",
"status": "Status",
"teamRole": "Função na equipe",
"period": "Período"
"period": "Período",
"device": "Dispositivo"
},
"tabs": {
"account": "Conta",
@@ -1880,7 +1891,10 @@
"statusUnknown": "Não testado",
"testConnection": "Testar conexão",
"disconnect": "Desconectar"
}
},
"deviceOrdinal": "{{ordinal}} de {{count}}",
"automationPrimaryOnly": "A automação do navegador funciona apenas no seu dispositivo principal (Dispositivo 1). Saia da conta nele para usá-la aqui.",
"automationActiveHere": "A automação do navegador está ativa neste dispositivo."
},
"shortcutsPage": {
"title": "Atalhos de teclado",
@@ -1918,5 +1932,90 @@
"description": "Você deseja enviar o aplicativo para a bandeja do sistema ou sair?",
"minimize": "Minimizar para a bandeja",
"quit": "Sair"
},
"tray": {
"show": "Mostrar Donut Browser",
"quit": "Sair"
},
"browserSupport": {
"endingSoonTitle": "O suporte ao navegador terminará em breve",
"endingSoonDescription": "O suporte aos seguintes perfis será removido em 15 de março de 2026: {{profiles}}. Migre para perfis Wayfern ou Camoufox."
},
"onboarding": {
"steps": {
"createProfile": {
"title": "Crie seu primeiro perfil",
"content": "Clique aqui para criar seu primeiro perfil. Escolha o Wayfern como navegador: o Chromium recomendado e protegido contra fingerprint."
},
"dnsBlocking": {
"title": "Bloqueio de DNS",
"content": "Use este menu para definir o nível da lista de bloqueio DNS do perfil — ele bloqueia anúncios, rastreadores e malware no nível da rede. Níveis mais altos bloqueiam mais."
}
},
"buttons": {
"skip": "Pular",
"back": "Voltar",
"next": "Próximo",
"finish": "Concluir"
},
"thankYou": {
"title": "Obrigado por escolher o Donut Browser",
"body": "Com sorte, deixará sua navegação mais privada: cada identidade separada e nada saindo do seu dispositivo. Boa navegação!",
"cta": "Começar a navegar"
}
},
"welcome": {
"title": "Boas-vindas ao Donut Browser",
"tagline": "Um navegador anti-detecção de código aberto para gerenciar várias identidades ao mesmo tempo.",
"skip": "Pular",
"next": "Próximo",
"permissions": {
"title": "Permitir microfone e câmera",
"desc": "Conceda acesso para que sites que precisam de microfone ou câmera funcionem nos seus perfis de navegador. O macOS pergunta uma vez; cada site ainda pedirá individualmente.",
"skip": "Agora não",
"grant": "Permitir acesso",
"requesting": "Solicitando…"
},
"ready": {
"title": "Preparando tudo",
"descDownloading": "Baixando seu primeiro navegador (Wayfern). Esta configuração única é executada em segundo plano — aguarde um momento.",
"descReady": "Seu navegador está pronto. Vamos criar seu primeiro perfil.",
"cta": "Criar meu primeiro perfil",
"downloading": "Baixando…",
"extracting": "Extraindo…",
"stats": "{{downloaded}} de {{total}}",
"speed": "{{speed}}/s",
"timeLeft": "{{time}} restante",
"descExtracting": "Extraindo seu navegador. Esta configuração única é executada em segundo plano — aguarde.",
"almostFinished": "Quase terminando…",
"errorTitle": "Falha na configuração",
"errorDownload": "Não foi possível baixar o {{browser}}. Verifique sua conexão e tente novamente.",
"errorExtraction": "Não foi possível extrair o {{browser}}. Tente novamente.",
"errorGeneric": "Algo deu errado ao configurar o {{browser}}. Tente novamente.",
"retry": "Tentar novamente"
},
"features": {
"title": "Recursos",
"items": {
"setDefault": "Definir como navegador padrão",
"proxy": "Suporte a proxy (HTTP/SOCKS5)",
"vpn": "Suporte a VPN (WireGuard)",
"profiles": "Perfis locais ilimitados",
"api": "API de gerenciamento de perfis e MCP",
"openSource": "Código aberto",
"groups": "Grupos de perfis",
"cookies": "Importar e exportar cookies"
}
},
"license": {
"title": "Licenciamento",
"body": "O Donut Browser é de código aberto e gratuito.",
"agree": "Entendi",
"personalTitle": "Uso pessoal",
"personalDesc": "Gratuito para sempre.",
"commercialTitle": "Uso comercial",
"trialBadge": "2 semanas grátis",
"commercialDesc": "Gratuito durante uma avaliação de 2 semanas. Depois, um plano pago mantém o projeto ativo e próspero."
}
}
}
+120 -21
View File
@@ -33,7 +33,8 @@
"minimize": "Свернуть",
"saving": "Сохраняем…",
"saved": "Сохранено",
"copied": "Скопировано"
"copied": "Скопировано",
"learnMore": "Подробнее"
},
"status": {
"active": "Активен",
@@ -99,6 +100,9 @@
"srOnly": {
"copy": "Скопировать",
"copied": "Скопировано"
},
"placeholders": {
"example": "напр., {{value}}"
}
},
"settings": {
@@ -340,7 +344,8 @@
"downloading": "Загрузка версии {{browser}} ({{version}})...",
"latestNeedsDownload": "Последнюю версию ({{version}}) необходимо скачать",
"latestAvailable": "Последняя версия ({{version}}) доступна",
"latestDownloading": "Загрузка версии ({{version}})..."
"latestDownloading": "Загрузка версии ({{version}})...",
"upgradeAvailable": "Доступна новая версия ({{version}}) {{browser}}."
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "На базе Wayfern",
@@ -351,7 +356,9 @@
"passwordProtect": {
"label": "Защитить этот профиль паролем",
"description": "Шифрует данные профиля на диске. Требуется для запуска."
}
},
"downloadingSubtitle": "Загрузка…",
"browsersDownloading": "Браузеры ещё загружаются. Создание профилей станет доступно после завершения загрузки."
},
"deleteDialog": {
"title": "Удалить профиль",
@@ -1188,7 +1195,9 @@
"cannotWhileRunning": "Остановите профиль перед сменой пароля."
},
"fingerprint": {
"notSupported": "Редактирование отпечатков доступно только для профилей Camoufox и Wayfern."
"notSupported": "Редактирование отпечатков доступно только для профилей Camoufox и Wayfern.",
"lockedTitle": "Отпечаток — функция Pro",
"lockedDescription": "Для просмотра и редактирования отпечатка профиля требуется активный платный план. Оформите подписку, чтобы разблокировать защиту от отпечатков."
}
},
"extensions": {
@@ -1524,17 +1533,6 @@
"creatingButton": "Создание...",
"createButton": "Создать"
},
"launchOnLogin": {
"title": "Запускать при входе?",
"description": "Работа в фоновом режиме помогает поддерживать прокси и браузеры активными.",
"declineButton": "Больше не спрашивать",
"declining": "...",
"enableButton": "Включить",
"enableSuccess": "Запуск при входе включен",
"enableFailed": "Не удалось включить запуск при входе",
"declineFailed": "Не удалось сохранить настройку",
"tryAgain": "Пожалуйста, попробуйте снова"
},
"wayfernTerms": {
"title": "Условия использования Wayfern",
"description": "Прежде чем использовать Donut Browser, необходимо прочитать и согласиться с Условиями использования Wayfern.",
@@ -1680,7 +1678,8 @@
"viewRelease": "Посмотреть релиз",
"later": "Позже",
"uploading": "Загрузка",
"downloading": "Скачивание"
"downloading": "Скачивание",
"startingUpdate": "Запуск обновления..."
}
},
"browserDownload": {
@@ -1694,7 +1693,9 @@
"extractionFailedDescription": "Повреждённый файл удалён. Он будет повторно загружен при следующей попытке.",
"extracting": "Распаковка файлов браузера... Не закрывайте приложение.",
"verifying": "Проверка файлов браузера...",
"downloadingRolling": "Загрузка rolling release сборки..."
"downloadingRolling": "Загрузка rolling release сборки...",
"geoipDownloading": "Загрузка базы данных GeoIP",
"geoipDownloaded": "База данных GeoIP успешно загружена!"
}
},
"versionUpdater": {
@@ -1712,7 +1713,12 @@
"updateSuccessDescription": "Найдено {{newVersions}} новых версий для {{successfulUpdates}} браузеров. Автоматическая загрузка начнётся в ближайшее время.",
"upToDate": "Новых версий браузеров не найдено",
"upToDateDescription": "Все версии браузеров актуальны",
"updateAllFailed": "Не удалось обновить версии браузеров"
"updateAllFailed": "Не удалось обновить версии браузеров",
"updateStarted": "Обновление {{browser}} началось",
"updateStartedDescription": "Загрузка версии {{version}} скоро начнётся. Запуск браузера отключён до завершения обновления.",
"downloadStarting": "Запуск загрузки {{browser}} {{version}}",
"downloadProgressBelow": "Прогресс загрузки будет показан ниже...",
"autoDownloadStarted": "Автоматическая загрузка {{browser}} {{version}}. Прогресс будет показан ниже."
}
},
"profilePassword": {
@@ -1800,7 +1806,11 @@
"invalidLaunchHookUrl": "Неверный URL хука запуска. Используйте полный URL http:// или https://.",
"cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.",
"cookieDbUnavailable": "Не удалось прочитать куки — хранилище куки недоступно.",
"selfHostedRequiresLogout": "Выйдите из аккаунта Donut, прежде чем настраивать собственный сервер."
"selfHostedRequiresLogout": "Выйдите из аккаунта Donut, прежде чем настраивать собственный сервер.",
"fingerprintRequiresPro": "Для защиты от отпечатков требуется активный платный план.",
"proxyNotWorking": "Выбранный прокси не работает, поэтому профиль не создан.",
"proxyPaymentRequired": "Выбранный прокси требует оплаты (402) — возможно, его подписка истекла — поэтому профиль не создан.",
"vpnNotWorking": "Выбранный VPN не работает, поэтому профиль не создан."
},
"rail": {
"profiles": "Профили",
@@ -1866,7 +1876,8 @@
"plan": "Тариф",
"status": "Статус",
"teamRole": "Роль в команде",
"period": "Период"
"period": "Период",
"device": "Устройство"
},
"tabs": {
"account": "Аккаунт",
@@ -1880,7 +1891,10 @@
"statusUnknown": "Не проверено",
"testConnection": "Проверить соединение",
"disconnect": "Отключить"
}
},
"deviceOrdinal": "{{ordinal}} из {{count}}",
"automationPrimaryOnly": "Автоматизация браузера работает только на вашем основном устройстве (Устройство 1). Выйдите из аккаунта на нём, чтобы использовать её здесь.",
"automationActiveHere": "Автоматизация браузера активна на этом устройстве."
},
"shortcutsPage": {
"title": "Сочетания клавиш",
@@ -1918,5 +1932,90 @@
"description": "Свернуть приложение в системный трей или выйти?",
"minimize": "Свернуть в трей",
"quit": "Выйти"
},
"tray": {
"show": "Показать Donut Browser",
"quit": "Выход"
},
"browserSupport": {
"endingSoonTitle": "Поддержка браузера скоро завершится",
"endingSoonDescription": "Поддержка следующих профилей будет прекращена 15 марта 2026 г.: {{profiles}}. Перейдите на профили Wayfern или Camoufox."
},
"onboarding": {
"steps": {
"createProfile": {
"title": "Создайте первый профиль",
"content": "Нажмите здесь, чтобы создать первый профиль. Выберите Wayfern в качестве браузера — рекомендуемый Chromium с защитой от цифровых отпечатков."
},
"dnsBlocking": {
"title": "DNS-блокировка",
"content": "Используйте этот список, чтобы задать уровень DNS-блокировки для профиля — он блокирует рекламу, трекеры и вредоносное ПО на сетевом уровне. Чем выше уровень, тем больше блокируется."
}
},
"buttons": {
"skip": "Пропустить",
"back": "Назад",
"next": "Далее",
"finish": "Готово"
},
"thankYou": {
"title": "Спасибо, что выбрали Donut Browser",
"body": "Пусть он сделает ваш просмотр интернета более приватным — каждая личность отдельно, и ничего не покидает ваше устройство. Приятного использования!",
"cta": "Начать работу"
}
},
"welcome": {
"title": "Добро пожаловать в Donut Browser",
"tagline": "Браузер с защитой от обнаружения с открытым исходным кодом для управления множеством личностей одновременно.",
"skip": "Пропустить",
"next": "Далее",
"permissions": {
"title": "Разрешить микрофон и камеру",
"desc": "Предоставьте доступ, чтобы сайты, которым нужны микрофон или камера, работали в ваших профилях браузера. macOS спросит один раз; каждый сайт всё равно запросит отдельно.",
"skip": "Не сейчас",
"grant": "Разрешить доступ",
"requesting": "Запрос…"
},
"ready": {
"title": "Настраиваем",
"descDownloading": "Загружаем ваш первый браузер (Wayfern). Эта однократная настройка выполняется в фоне — подождите немного.",
"descReady": "Браузер готов. Давайте создадим ваш первый профиль.",
"cta": "Создать первый профиль",
"downloading": "Загрузка…",
"extracting": "Распаковка…",
"stats": "{{downloaded}} из {{total}}",
"speed": "{{speed}}/с",
"timeLeft": "осталось {{time}}",
"descExtracting": "Распаковка браузера. Эта однократная настройка выполняется в фоновом режиме — подождите.",
"almostFinished": "Почти готово…",
"errorTitle": "Ошибка настройки",
"errorDownload": "Не удалось загрузить {{browser}}. Проверьте подключение и повторите попытку.",
"errorExtraction": "Не удалось распаковать {{browser}}. Повторите попытку.",
"errorGeneric": "Что-то пошло не так при настройке {{browser}}. Повторите попытку.",
"retry": "Повторить"
},
"features": {
"title": "Возможности",
"items": {
"setDefault": "Сделать браузером по умолчанию",
"proxy": "Поддержка прокси (HTTP/SOCKS5)",
"vpn": "Поддержка VPN (WireGuard)",
"profiles": "Неограниченное число локальных профилей",
"api": "API управления профилями и MCP",
"openSource": "Открытый исходный код",
"groups": "Группы профилей",
"cookies": "Импорт и экспорт cookie"
}
},
"license": {
"title": "Лицензия",
"body": "Donut Browser имеет открытый исходный код и бесплатен в использовании.",
"agree": "Понятно",
"personalTitle": "Личное использование",
"personalDesc": "Бесплатно навсегда.",
"commercialTitle": "Коммерческое использование",
"trialBadge": "2 недели бесплатно",
"commercialDesc": "Бесплатно в течение 2-недельного ознакомительного периода. После этого требуется платный план, что помогает поддерживать и развивать проект."
}
}
}
+120 -21
View File
@@ -33,7 +33,8 @@
"minimize": "最小化",
"saving": "正在保存…",
"saved": "已保存",
"copied": "已复制"
"copied": "已复制",
"learnMore": "了解更多"
},
"status": {
"active": "活跃",
@@ -99,6 +100,9 @@
"srOnly": {
"copy": "复制",
"copied": "已复制"
},
"placeholders": {
"example": "例如:{{value}}"
}
},
"settings": {
@@ -340,7 +344,8 @@
"downloading": "正在下载 {{browser}} 版本 ({{version}})...",
"latestNeedsDownload": "最新版本 ({{version}}) 需要下载",
"latestAvailable": "最新版本 ({{version}}) 可用",
"latestDownloading": "正在下载版本 ({{version}})..."
"latestDownloading": "正在下载版本 ({{version}})...",
"upgradeAvailable": "{{browser}} 有新版本({{version}})可用。"
},
"chromiumLabel": "Chromium",
"chromiumSubtitle": "由 Wayfern 驱动",
@@ -351,7 +356,9 @@
"passwordProtect": {
"label": "为此配置文件设置密码保护",
"description": "加密磁盘上的配置文件数据。启动时需要密码。"
}
},
"downloadingSubtitle": "正在下载…",
"browsersDownloading": "浏览器仍在下载中。下载完成后即可创建配置文件。"
},
"deleteDialog": {
"title": "删除配置文件",
@@ -1188,7 +1195,9 @@
"cannotWhileRunning": "更改密码前请先停止此配置文件。"
},
"fingerprint": {
"notSupported": "指纹编辑仅适用于 Camoufox 和 Wayfern 配置文件。"
"notSupported": "指纹编辑仅适用于 Camoufox 和 Wayfern 配置文件。",
"lockedTitle": "指纹是 Pro 功能",
"lockedDescription": "查看和编辑配置文件的指纹需要有效的付费方案。升级后即可解锁指纹保护。"
}
},
"extensions": {
@@ -1524,17 +1533,6 @@
"creatingButton": "正在创建...",
"createButton": "创建"
},
"launchOnLogin": {
"title": "启用登录时启动?",
"description": "在后台运行有助于保持代理和浏览器存活。",
"declineButton": "不再询问",
"declining": "...",
"enableButton": "启用",
"enableSuccess": "已启用登录时启动",
"enableFailed": "启用登录时启动失败",
"declineFailed": "保存偏好失败",
"tryAgain": "请重试"
},
"wayfernTerms": {
"title": "Wayfern 条款和条件",
"description": "在使用 Donut Browser 之前,你必须阅读并同意 Wayfern 的条款和条件。",
@@ -1680,7 +1678,8 @@
"viewRelease": "查看版本",
"later": "稍后",
"uploading": "上传中",
"downloading": "下载中"
"downloading": "下载中",
"startingUpdate": "正在开始更新..."
}
},
"browserDownload": {
@@ -1694,7 +1693,9 @@
"extractionFailedDescription": "损坏的文件已删除。下次尝试时将重新下载。",
"extracting": "正在提取浏览器文件...请不要关闭应用。",
"verifying": "正在验证浏览器文件...",
"downloadingRolling": "正在下载滚动发布版本..."
"downloadingRolling": "正在下载滚动发布版本...",
"geoipDownloading": "正在下载 GeoIP 数据库",
"geoipDownloaded": "GeoIP 数据库下载成功!"
}
},
"versionUpdater": {
@@ -1712,7 +1713,12 @@
"updateSuccessDescription": "在 {{successfulUpdates}} 个浏览器中发现 {{newVersions}} 个新版本。自动下载即将开始。",
"upToDate": "未发现新的浏览器版本",
"upToDateDescription": "所有浏览器版本都是最新的",
"updateAllFailed": "更新浏览器版本失败"
"updateAllFailed": "更新浏览器版本失败",
"updateStarted": "{{browser}} 更新已开始",
"updateStartedDescription": "版本 {{version}} 即将开始下载。更新完成前浏览器启动将被禁用。",
"downloadStarting": "正在开始下载 {{browser}} {{version}}",
"downloadProgressBelow": "下载进度将显示在下方...",
"autoDownloadStarted": "正在自动下载 {{browser}} {{version}}。进度将显示在下方。"
}
},
"profilePassword": {
@@ -1800,7 +1806,11 @@
"invalidLaunchHookUrl": "启动钩子 URL 无效。请使用完整的 http:// 或 https:// URL。",
"cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。",
"cookieDbUnavailable": "无法读取 Cookie — Cookie 存储不可用。",
"selfHostedRequiresLogout": "在配置自托管服务器之前请先退出 Donut 账户。"
"selfHostedRequiresLogout": "在配置自托管服务器之前请先退出 Donut 账户。",
"fingerprintRequiresPro": "指纹保护需要有效的付费方案。",
"proxyNotWorking": "所选代理无法使用,因此未创建配置文件。",
"proxyPaymentRequired": "所选代理需要付费(402),其订阅可能已过期,因此未创建配置文件。",
"vpnNotWorking": "所选 VPN 无法使用,因此未创建配置文件。"
},
"rail": {
"profiles": "配置文件",
@@ -1866,7 +1876,8 @@
"plan": "套餐",
"status": "状态",
"teamRole": "团队角色",
"period": "计费周期"
"period": "计费周期",
"device": "设备"
},
"tabs": {
"account": "账户",
@@ -1880,7 +1891,10 @@
"statusUnknown": "未测试",
"testConnection": "测试连接",
"disconnect": "断开连接"
}
},
"deviceOrdinal": "第 {{ordinal}} 台,共 {{count}} 台",
"automationPrimaryOnly": "浏览器自动化仅在您的主设备(设备 1)上运行。请在该设备上退出登录,才能在此设备上使用。",
"automationActiveHere": "浏览器自动化已在此设备上启用。"
},
"shortcutsPage": {
"title": "键盘快捷键",
@@ -1918,5 +1932,90 @@
"description": "您想将应用最小化到系统托盘还是退出?",
"minimize": "最小化到托盘",
"quit": "退出"
},
"tray": {
"show": "显示 Donut Browser",
"quit": "退出"
},
"browserSupport": {
"endingSoonTitle": "浏览器支持即将结束",
"endingSoonDescription": "以下配置文件的支持将于 2026 年 3 月 15 日移除:{{profiles}}。请迁移到 Wayfern 或 Camoufox 配置文件。"
},
"onboarding": {
"steps": {
"createProfile": {
"title": "创建你的第一个配置文件",
"content": "点击这里创建您的第一个配置文件。浏览器请选择 Wayfern——推荐的、具备指纹保护的 Chromium。"
},
"dnsBlocking": {
"title": "DNS 拦截",
"content": "使用此下拉菜单为配置文件设置 DNS 拦截级别——在网络层面拦截广告、跟踪器和恶意软件。级别越高,拦截越多。"
}
},
"buttons": {
"skip": "跳过",
"back": "返回",
"next": "下一步",
"finish": "完成"
},
"thankYou": {
"title": "感谢您选择 Donut Browser",
"body": "希望它能让您的网络浏览更加私密——每个身份彼此独立,数据不会离开您的设备。祝您使用愉快!",
"cta": "开始浏览"
}
},
"welcome": {
"title": "欢迎使用 Donut Browser",
"tagline": "一款开源的反检测浏览器,可同时管理多个身份。",
"skip": "跳过",
"next": "下一步",
"permissions": {
"title": "允许使用麦克风和摄像头",
"desc": "授予权限,让需要麦克风或摄像头的网站在你的浏览器配置文件中正常工作。macOS 只询问一次;每个网站仍会单独请求。",
"skip": "暂不",
"grant": "允许访问",
"requesting": "正在请求…"
},
"ready": {
"title": "正在准备",
"descDownloading": "正在下载你的第一个浏览器(Wayfern)。此一次性设置在后台运行——请稍候。",
"descReady": "你的浏览器已就绪。来创建你的第一个配置文件吧。",
"cta": "创建我的第一个配置文件",
"downloading": "正在下载…",
"extracting": "正在解压…",
"stats": "{{downloaded}} / {{total}}",
"speed": "{{speed}}/秒",
"timeLeft": "剩余 {{time}}",
"descExtracting": "正在解压浏览器。此一次性设置在后台运行,请稍候。",
"almostFinished": "即将完成…",
"errorTitle": "设置失败",
"errorDownload": "无法下载 {{browser}}。请检查网络连接后重试。",
"errorExtraction": "无法解压 {{browser}}。请重试。",
"errorGeneric": "设置 {{browser}} 时出现问题。请重试。",
"retry": "重试"
},
"features": {
"title": "功能",
"items": {
"setDefault": "设为默认浏览器",
"proxy": "代理支持(HTTP/SOCKS5",
"vpn": "VPN 支持(WireGuard",
"profiles": "无限本地配置文件",
"api": "配置文件管理 API 和 MCP",
"openSource": "开源",
"groups": "配置文件分组",
"cookies": "Cookie 导入与导出"
}
},
"license": {
"title": "许可",
"body": "Donut Browser 是开源软件,可免费使用。",
"agree": "我知道了",
"personalTitle": "个人使用",
"personalDesc": "永久免费。",
"commercialTitle": "商业使用",
"trialBadge": "2 周免费",
"commercialDesc": "在 2 周评估期内免费。之后需要付费方案,这有助于本项目的持续维护与发展。"
}
}
}
+12
View File
@@ -28,6 +28,10 @@ export type BackendErrorCode =
| "CANNOT_MODIFY_CLOUD_MANAGED_PROXY"
| "SYNC_LOCKED_BY_PROFILE"
| "SYNC_NOT_CONFIGURED"
| "FINGERPRINT_REQUIRES_PRO"
| "PROXY_NOT_WORKING"
| "PROXY_PAYMENT_REQUIRED"
| "VPN_NOT_WORKING"
| "INTERNAL_ERROR";
export interface BackendError {
@@ -120,6 +124,14 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.syncLockedByProfile");
case "SYNC_NOT_CONFIGURED":
return t("backendErrors.syncNotConfigured");
case "FINGERPRINT_REQUIRES_PRO":
return t("backendErrors.fingerprintRequiresPro");
case "PROXY_NOT_WORKING":
return t("backendErrors.proxyNotWorking");
case "PROXY_PAYMENT_REQUIRED":
return t("backendErrors.proxyPaymentRequired");
case "VPN_NOT_WORKING":
return t("backendErrors.vpnNotWorking");
case "INTERNAL_ERROR":
return t("backendErrors.internal", {
detail: parsed.params?.detail ?? "",
+18
View File
@@ -0,0 +1,18 @@
// Lightweight module-level flag for whether the first-run onboarding is in
// progress. The welcome dialog shows its own browser-setup progress, so the
// global browser-download toasts (from use-browser-download) are suppressed
// while this is true to avoid a competing toast during onboarding.
let active = false;
export function setOnboardingActive(value: boolean): void {
active = value;
}
export function isOnboardingActive(): boolean {
return active;
}
// Dispatched on `window` when the product tour reaches its end and the user
// clicks "Finish" (not when they skip early). The page listens for it to show
// the celebratory thank-you dialog.
export const ONBOARDING_TOUR_FINISHED_EVENT = "donut:onboarding-tour-finished";
+9 -3
View File
@@ -10,6 +10,9 @@ interface BaseToastProps {
duration?: number;
action?: ExternalToast["action"];
onCancel?: () => void;
// When false, the toast cannot be dismissed by the user (no swipe; combine
// with duration: Infinity and no onCancel to make it fully non-closable).
dismissible?: boolean;
}
interface LoadingToastProps extends BaseToastProps {
@@ -110,12 +113,13 @@ export function showToast(props: ToastProps & { id?: string }) {
sonnerToast.custom(() => React.createElement(UnifiedToast, props), {
id: toastId,
duration,
dismissible: props.dismissible,
style: {
background: "transparent",
border: "none",
boxShadow: "none",
padding: 0,
zIndex: 99999,
zIndex: 10001,
pointerEvents: "auto",
},
});
@@ -123,12 +127,13 @@ export function showToast(props: ToastProps & { id?: string }) {
sonnerToast.custom(() => React.createElement(UnifiedToast, props), {
id: toastId,
duration,
dismissible: props.dismissible,
style: {
background: "transparent",
border: "none",
boxShadow: "none",
padding: 0,
zIndex: 99999,
zIndex: 10001,
pointerEvents: "auto",
},
});
@@ -136,12 +141,13 @@ export function showToast(props: ToastProps & { id?: string }) {
sonnerToast.custom(() => React.createElement(UnifiedToast, props), {
id: toastId,
duration,
dismissible: props.dismissible,
style: {
background: "transparent",
border: "none",
boxShadow: "none",
padding: 0,
zIndex: 99999,
zIndex: 10001,
pointerEvents: "auto",
},
});
+6
View File
@@ -89,6 +89,12 @@ export interface CloudUser {
teamId?: string;
teamName?: string;
teamRole?: string;
// This device's position among the user's active devices (oldest = 1).
// Ordinal 1 / isPrimaryDevice === true is the only device that can run
// browser automation. Optional: older backends omit them.
deviceOrdinal?: number | null;
deviceCount?: number | null;
isPrimaryDevice?: boolean | null;
}
export interface ProfileLockInfo {