mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-28 05:40:25 +02:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15b51f8d2d | ||
|
|
63f673e7d4 | ||
|
|
7671b655cc | ||
|
|
82271d8c3b | ||
|
|
b7e1c791db | ||
|
|
4b1d48e1ef | ||
|
|
abe210eda3 | ||
|
|
e873a72387 | ||
|
|
d346c134b0 | ||
|
|
443f8b4597 | ||
|
|
e0b6504e9e | ||
|
|
6d3f2c6cbf | ||
|
|
8b4f657e15 | ||
|
|
405e11eeb6 | ||
|
|
90f1bf2569 | ||
|
|
33d3af0386 | ||
|
|
de88fbbafe | ||
|
|
21a835a942 | ||
|
|
4e1ec4b882 | ||
|
|
949d2c71de |
@@ -31,14 +31,57 @@ env:
|
||||
IMAGE_NAME: donutbrowser/donut-sync
|
||||
|
||||
jobs:
|
||||
# donut-sync's own end-to-end suite covers which host it signs into presigned
|
||||
# URLs. That is the whole of the self-hosted sync failure in issue 534: sign
|
||||
# against an address only the server can reach and every client transfer dies
|
||||
# at connect while /health and /readyz stay green. The suite existed and was
|
||||
# never run by anything, so the guard was decorative. Run it here, before the
|
||||
# image ships, because an image with broken presigning is the thing that
|
||||
# reaches users.
|
||||
#
|
||||
# Ubuntu only, and separate from the Rust and Node matrices, because it needs
|
||||
# Docker for MinIO and a POSIX env-var prefix in the package script.
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Publishes MinIO on 8987, which is the port test/test-env.ts pins.
|
||||
- name: Start test storage
|
||||
run: docker compose -f donut-sync/docker-compose.yml up -d --wait
|
||||
|
||||
- name: Run donut-sync end-to-end tests
|
||||
working-directory: ./donut-sync
|
||||
run: pnpm test:e2e
|
||||
|
||||
- name: Stop test storage
|
||||
if: always()
|
||||
run: docker compose -f donut-sync/docker-compose.yml down -v
|
||||
|
||||
build-and-push:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c #v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e #v4.3.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f #v4.6.0
|
||||
|
||||
@@ -693,7 +693,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@02546dfc2e4515a4f90aaf9ceb3890df2ac2b479 #v1.18.17
|
||||
uses: anomalyco/opencode/github@31406ccc51b4bd2a4e1e086b2bcaa5f7f804f26d #v1.18.18
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
|
||||
uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 #master
|
||||
with:
|
||||
toolchain: stable
|
||||
components: rustfmt, clippy
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
scan-scheduled:
|
||||
name: Scheduled Security Scan
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
scan-pr:
|
||||
name: PR Security Scan
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master
|
||||
uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
security-scan:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
|
||||
uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 #master
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
security-scan:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
|
||||
uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 #master
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
# testing
|
||||
/coverage
|
||||
/e2e/app/target/
|
||||
/e2e/app/Cargo.lock
|
||||
/e2e/.driver/
|
||||
|
||||
# next.js
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
23
|
||||
24
|
||||
|
||||
|
||||
@@ -102,6 +102,10 @@ into the ignored `e2e/.driver` root) and launch an `e2e`-feature build.
|
||||
Every session gets its own temporary Donut data/cache/log root, home directory,
|
||||
WebView store, ports, and sync bucket. Never point a suite at production or development data.
|
||||
|
||||
`e2e/app/Cargo.lock` is generated, gitignored, and never edited by hand. `e2e/run.mjs` seeds it
|
||||
from `src-tauri/Cargo.lock` whenever that file is newer, so the harness always links the exact
|
||||
dependency versions Donut ships and a version bump or a Dependabot upgrade needs no second edit.
|
||||
|
||||
After a behavior change, run the smallest affected subset below in addition to the standard
|
||||
format/lint/unit-test command. A code change is not verified until its affected native
|
||||
suite passes:
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## v0.29.6 (2026-08-24)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- cleanup
|
||||
|
||||
### Documentation
|
||||
|
||||
- readme
|
||||
- switch to svg
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: linting
|
||||
- chore: version bump
|
||||
- chore: linting
|
||||
- test: integration cleanup
|
||||
- chore: switch to gitdebt
|
||||
- chore: update flake.nix for v0.29.5 [skip ci] (#563)
|
||||
|
||||
|
||||
## v0.29.5 (2026-08-16)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_x64.dmg) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
@@ -56,15 +56,15 @@ brew install --cask donut
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_x64-portable.zip)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut-0.29.5-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut-0.29.5-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_aarch64.AppImage) |
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut-0.29.6-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut-0.29.6-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
@@ -120,8 +120,8 @@ Donut Browser is built by the people who use it, and plenty of the most useful h
|
||||
|
||||
<a href="https://gitdebt.com/zhom/donutbrowser?ref=readme">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.gitdebt.com/api/repos/zhom/donutbrowser/stats/contributors.svg?theme=dark" />
|
||||
<img alt="Everyone who has landed commits in zhom/donutbrowser, ranked by commit count" src="https://api.gitdebt.com/api/repos/zhom/donutbrowser/stats/contributors.svg?theme=light" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.gitdebt.com/api/repos/zhom/donutbrowser/stats/contributors.svg?theme=dark&animate=1" />
|
||||
<img alt="Everyone who has landed commits in zhom/donutbrowser, ranked by commit count" src="https://api.gitdebt.com/api/repos/zhom/donutbrowser/stats/contributors.svg?theme=light&animate=1" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
# Storage for developing and testing donut-sync itself. It runs MinIO only, and
|
||||
# the sync server is expected to run on the host beside it (`pnpm start:dev`),
|
||||
# which is why MinIO is published and why the port matches the one pinned in
|
||||
# test/test-env.ts.
|
||||
#
|
||||
# This is NOT the self-hosting compose file. That one runs donut-sync in a
|
||||
# container too, and it must set S3_PUBLIC_ENDPOINT, because a server that signs
|
||||
# presigned URLs against a compose-internal host such as `http://minio:9000`
|
||||
# hands every device a URL it cannot open, while /health and /readyz stay green.
|
||||
# Take the self-hosting compose from https://donutbrowser.com/docs/self-hosting
|
||||
# rather than from here.
|
||||
services:
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
|
||||
+16
-11
@@ -18,29 +18,29 @@
|
||||
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1081.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1081.0",
|
||||
"@nestjs/common": "^11.1.27",
|
||||
"@aws-sdk/client-s3": "^3.1117.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1117.0",
|
||||
"@nestjs/common": "^11.2.2",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.27",
|
||||
"@nestjs/platform-express": "^11.1.27",
|
||||
"@nestjs/core": "^11.2.2",
|
||||
"@nestjs/platform-express": "^11.2.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.23",
|
||||
"@nestjs/cli": "^11.0.24",
|
||||
"@nestjs/schematics": "^11.1.0",
|
||||
"@nestjs/testing": "^11.1.27",
|
||||
"@nestjs/testing": "^11.2.2",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@types/node": "^26.3.0",
|
||||
"@types/supertest": "^7.2.1",
|
||||
"jest": "^30.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.11",
|
||||
"ts-jest": "^29.4.12",
|
||||
"ts-loader": "^9.6.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
@@ -55,7 +55,12 @@
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
"^.+\\.(t|j)s$": [
|
||||
"ts-jest",
|
||||
{
|
||||
"tsconfig": "<rootDir>/../test/tsconfig.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1"
|
||||
|
||||
@@ -86,6 +86,11 @@ export class SyncService implements OnModuleInit {
|
||||
// `S3_PUBLIC_ENDPOINT` names a different, client-reachable address.
|
||||
private presignClient: S3Client;
|
||||
private publicEndpoint: string;
|
||||
/**
|
||||
* Whether an operator chose the public endpoint, or it fell back to the
|
||||
* server's own storage address. The fallback is the shape that fails.
|
||||
*/
|
||||
private publicEndpointWasConfigured: boolean;
|
||||
private bucket: string;
|
||||
// Upper bound on presign batch array length (DoS guard).
|
||||
private static readonly MAX_BATCH_ITEMS = 1000;
|
||||
@@ -131,9 +136,11 @@ export class SyncService implements OnModuleInit {
|
||||
// network and nowhere else. Signing is bound to the host, so the presign
|
||||
// client is a second client pinned to the public address rather than a
|
||||
// string rewrite of the signed URL.
|
||||
const publicEndpoint =
|
||||
this.configService.get<string>("S3_PUBLIC_ENDPOINT") || endpoint;
|
||||
const configuredPublicEndpoint =
|
||||
this.configService.get<string>("S3_PUBLIC_ENDPOINT");
|
||||
const publicEndpoint = configuredPublicEndpoint || endpoint;
|
||||
this.publicEndpoint = publicEndpoint;
|
||||
this.publicEndpointWasConfigured = Boolean(configuredPublicEndpoint);
|
||||
this.presignClient =
|
||||
publicEndpoint === endpoint
|
||||
? this.s3Client
|
||||
@@ -191,14 +198,33 @@ export class SyncService implements OnModuleInit {
|
||||
|
||||
const isSingleLabel =
|
||||
!host.includes(".") && !host.includes(":") && host !== "localhost";
|
||||
if (!isSingleLabel) return;
|
||||
|
||||
this.logger.warn(
|
||||
`Storage endpoint '${this.publicEndpoint}' uses the container-only host '${host}'. ` +
|
||||
"Presigned URLs built from it cannot be reached by Donut Browser, so every " +
|
||||
"transfer will fail while /health and /readyz stay green. Set S3_PUBLIC_ENDPOINT " +
|
||||
"to an address your devices can reach (and publish that port).",
|
||||
);
|
||||
if (isSingleLabel) {
|
||||
this.logger.warn(
|
||||
`Storage endpoint '${this.publicEndpoint}' uses the container-only host '${host}'. ` +
|
||||
"Presigned URLs built from it cannot be reached by Donut Browser, so every " +
|
||||
"transfer will fail while /health and /readyz stay green. Set S3_PUBLIC_ENDPOINT " +
|
||||
"to an address your devices can reach (and publish that port).",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// A dotted host proves nothing. With `S3_PUBLIC_ENDPOINT` unset, clients are
|
||||
// handed whatever address this server uses for storage itself, and a
|
||||
// reachable-looking name such as `storage.internal`, or a private address on
|
||||
// a network the devices are not on, fails in exactly the same way while
|
||||
// saying nothing at all. This server cannot test the endpoint for them,
|
||||
// because it does not know where its clients are, so state what it does
|
||||
// know and leave the judgement to the operator.
|
||||
if (!this.publicEndpointWasConfigured) {
|
||||
this.logger.log(
|
||||
`S3_PUBLIC_ENDPOINT is not set, so presigned URLs will name '${this.publicEndpoint}', ` +
|
||||
"the address this server uses for storage itself. Transfers go straight from each " +
|
||||
"device to that address, and this server cannot verify a device can reach it. If " +
|
||||
"transfers fail while /health and /readyz stay green, set S3_PUBLIC_ENDPOINT to an " +
|
||||
"address your devices can reach and publish that port.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureBucketExists(): Promise<void> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { INestApplication, Logger } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import request from "supertest";
|
||||
@@ -199,3 +199,72 @@ describe("presigned URL host", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// The server cannot test whether a device can reach the endpoint it signs, so
|
||||
// the only honest thing it can do is say what it is handing out. Without this,
|
||||
// the one configuration that breaks every transfer boots completely silently.
|
||||
describe("boot message about the presign endpoint", () => {
|
||||
let logs: string[];
|
||||
let warnings: string[];
|
||||
let logSpy: jest.SpyInstance;
|
||||
let warnSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
logs = [];
|
||||
warnings = [];
|
||||
logSpy = jest
|
||||
.spyOn(Logger.prototype, "log")
|
||||
.mockImplementation((message: unknown) => {
|
||||
logs.push(String(message));
|
||||
});
|
||||
warnSpy = jest
|
||||
.spyOn(Logger.prototype, "warn")
|
||||
.mockImplementation((message: unknown) => {
|
||||
warnings.push(String(message));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("says which host clients will be handed when S3_PUBLIC_ENDPOINT is unset", async () => {
|
||||
const app = await bootstrap(undefined);
|
||||
try {
|
||||
const spoken = [...logs, ...warnings].join("\n");
|
||||
expect(spoken).toContain("S3_PUBLIC_ENDPOINT");
|
||||
expect(spoken).toContain(TEST_S3_ENDPOINT);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
// A single-label host is the documented compose default and cannot work for
|
||||
// any client, so it earns a warning rather than a note.
|
||||
it("warns loudly about a container-only host", async () => {
|
||||
const app = await bootstrap("http://minio:9000");
|
||||
try {
|
||||
const spoken = warnings.join("\n");
|
||||
expect(spoken).toContain("minio");
|
||||
expect(spoken).toContain("S3_PUBLIC_ENDPOINT");
|
||||
} finally {
|
||||
delete process.env.S3_PUBLIC_ENDPOINT;
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
// An operator who set the variable made a choice. Repeating the note at them
|
||||
// would train them to ignore it, and the warning above is for the value that
|
||||
// provably cannot work, not for every value the server cannot verify.
|
||||
it("stays quiet when an operator has chosen a routable endpoint", async () => {
|
||||
const app = await bootstrap(PUBLIC_ENDPOINT);
|
||||
try {
|
||||
const spoken = [...logs, ...warnings].join("\n");
|
||||
expect(spoken).not.toContain("S3_PUBLIC_ENDPOINT is not set");
|
||||
} finally {
|
||||
delete process.env.S3_PUBLIC_ENDPOINT;
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+642
-757
File diff suppressed because it is too large
Load Diff
@@ -129,7 +129,8 @@ export const commandCoverage = {
|
||||
"read_profile_cookies",
|
||||
"get_profile_cookie_stats",
|
||||
"copy_profile_cookies",
|
||||
"import_cookies_from_file",
|
||||
"analyze_pasted_cookies",
|
||||
"import_pasted_cookies",
|
||||
"export_profile_cookies",
|
||||
"set_profile_password",
|
||||
"change_profile_password",
|
||||
@@ -212,6 +213,7 @@ export const commandCoverage = {
|
||||
commands: [
|
||||
"get_sync_settings",
|
||||
"save_sync_settings",
|
||||
"check_sync_server_connection",
|
||||
"cloud_auth::restart_sync_service",
|
||||
"set_profile_sync_mode",
|
||||
"cancel_profile_sync",
|
||||
|
||||
+28
-15
@@ -2,6 +2,7 @@
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import {
|
||||
copyFileSync,
|
||||
createReadStream,
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
@@ -44,6 +45,9 @@ const driverBinary = path.join(
|
||||
"bin",
|
||||
`tauri-wd${executableSuffix}`,
|
||||
);
|
||||
const appManifest = path.join(appManifestDir, "Cargo.toml");
|
||||
const appLockfile = path.join(appManifestDir, "Cargo.lock");
|
||||
const donutLockfile = path.join(projectRoot, "src-tauri", "Cargo.lock");
|
||||
|
||||
const suiteFiles = {
|
||||
smoke: ["diagnostics.test.mjs", "smoke.test.mjs", "coverage.test.mjs"],
|
||||
@@ -237,16 +241,13 @@ async function loadLocalValues(names) {
|
||||
return values;
|
||||
}
|
||||
|
||||
function lockedDriverVersion() {
|
||||
const lockfile = readFileSync(
|
||||
path.join(appManifestDir, "Cargo.lock"),
|
||||
"utf8",
|
||||
);
|
||||
const match = lockfile.match(
|
||||
/\[\[package\]\]\s*\nname = "tauri-wd"\s*\nversion = "([^"]+)"/,
|
||||
);
|
||||
function pinnedDriverVersion() {
|
||||
const manifest = readFileSync(appManifest, "utf8");
|
||||
const match = manifest.match(/^tauri-wd\s*=\s*"=([^"]+)"$/m);
|
||||
if (!match) {
|
||||
throw new Error("e2e/app/Cargo.lock does not resolve a tauri-wd version");
|
||||
throw new Error(
|
||||
'e2e/app/Cargo.toml must pin tauri-wd to an exact version, e.g. tauri-wd = "=0.1.11"',
|
||||
);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
@@ -263,7 +264,7 @@ function installedDriverVersion() {
|
||||
}
|
||||
|
||||
function ensureDriver() {
|
||||
const version = lockedDriverVersion();
|
||||
const version = pinnedDriverVersion();
|
||||
if (installedDriverVersion() === version) {
|
||||
log(`tauri-wd ${version} already installed at ${driverBinary}`);
|
||||
return;
|
||||
@@ -285,15 +286,27 @@ function ensureDriver() {
|
||||
);
|
||||
}
|
||||
|
||||
// The harness links the Donut crate, so it has to resolve the same versions
|
||||
// Donut itself ships. Seeding the harness lockfile from src-tauri/Cargo.lock
|
||||
// keeps the two in step whenever a dependency or the app version moves; cargo
|
||||
// fills in the harness-only packages on top. It is generated, never hand-edited.
|
||||
function syncHarnessLockfile() {
|
||||
if (
|
||||
existsSync(appLockfile) &&
|
||||
statSync(appLockfile).mtimeMs >= statSync(donutLockfile).mtimeMs
|
||||
) {
|
||||
return;
|
||||
}
|
||||
copyFileSync(donutLockfile, appLockfile);
|
||||
log("seeded e2e/app/Cargo.lock from src-tauri/Cargo.lock");
|
||||
}
|
||||
|
||||
function buildAll() {
|
||||
run("pnpm", ["build"], projectRoot);
|
||||
run("pnpm", ["copy-proxy-binary"], projectRoot);
|
||||
run(process.execPath, ["src-tauri/download-xray.mjs"], projectRoot);
|
||||
run(
|
||||
"cargo",
|
||||
["build", "--locked", "--manifest-path", "e2e/app/Cargo.toml"],
|
||||
projectRoot,
|
||||
);
|
||||
syncHarnessLockfile();
|
||||
run("cargo", ["build", "--manifest-path", "e2e/app/Cargo.toml"], projectRoot);
|
||||
ensureDriver();
|
||||
}
|
||||
|
||||
|
||||
@@ -217,11 +217,26 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
version: prepared.version,
|
||||
configJson: JSON.stringify({ geoip: false }),
|
||||
});
|
||||
const fingerprint = JSON.parse(sample);
|
||||
const fingerprint = JSON.parse(sample.fingerprint);
|
||||
assert.ok(
|
||||
Object.keys(fingerprint).length >= 10,
|
||||
"Wayfern returned an incomplete fingerprint",
|
||||
);
|
||||
// A browser with the identity API must hand back the UUID the device was
|
||||
// derived from, plus the pre-edit baseline the launch path diffs against.
|
||||
// Without both, the profile stores a device it cannot reproduce.
|
||||
const identityCapable =
|
||||
Number.parseInt(prepared.version.split(".")[0], 10) >= 151;
|
||||
assert.equal(
|
||||
typeof sample.identity_id === "string",
|
||||
identityCapable,
|
||||
"identity_id must be present exactly on browsers with the identity API",
|
||||
);
|
||||
assert.equal(
|
||||
typeof sample.identity_baseline === "string",
|
||||
identityCapable,
|
||||
"identity_baseline must be present exactly on browsers with the identity API",
|
||||
);
|
||||
|
||||
const profile = await createRealProfile(
|
||||
app,
|
||||
@@ -232,6 +247,13 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
assert.ok(
|
||||
Object.keys(JSON.parse(profile.wayfern_config.fingerprint)).length >= 10,
|
||||
);
|
||||
// Profile creation stores the identity alongside the device it derived, or
|
||||
// the launch path would treat the profile as un-migrated and replace it.
|
||||
assert.equal(
|
||||
typeof profile.wayfern_config.identity_id === "string",
|
||||
identityCapable,
|
||||
"a created profile must carry the identity its device came from",
|
||||
);
|
||||
assert.equal(await app.invoke("check_missing_geoip_database"), true);
|
||||
assert.equal(await app.invoke("is_geoip_database_available"), false);
|
||||
await app.invoke("download_geoip_database");
|
||||
@@ -245,6 +267,25 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
profileId: profile.id,
|
||||
exitIp: "8.8.8.8",
|
||||
});
|
||||
// The identity is internal state that neither call above sends back.
|
||||
// Losing it would silently re-mint the device on the next launch and throw
|
||||
// the user's edits away with it, so both paths must carry it forward
|
||||
// unchanged.
|
||||
if (identityCapable) {
|
||||
const stored = (await app.invoke("list_browser_profiles")).find(
|
||||
(p) => p.id === profile.id,
|
||||
);
|
||||
assert.equal(
|
||||
stored.wayfern_config.identity_id,
|
||||
profile.wayfern_config.identity_id,
|
||||
"the identity must survive update_wayfern_config and an exit re-match",
|
||||
);
|
||||
assert.equal(
|
||||
stored.wayfern_config.identity_baseline,
|
||||
profile.wayfern_config.identity_baseline,
|
||||
"the baseline must survive with the identity it describes",
|
||||
);
|
||||
}
|
||||
// Pre-launch gate: local-only checks that must answer without starting a
|
||||
// proxy, an Xray worker or the browser.
|
||||
const checks = await app.invoke("get_profile_pre_launch_checks", {
|
||||
|
||||
@@ -766,11 +766,16 @@ test("cookie import/copy/export, profile encryption, and traffic-stat read/clear
|
||||
expirationDate: 2_000_000_000,
|
||||
},
|
||||
]);
|
||||
const imported = await app.invoke("import_cookies_from_file", {
|
||||
const imported = await app.invoke("import_pasted_cookies", {
|
||||
profileId: source.id,
|
||||
content: cookieJson,
|
||||
site: null,
|
||||
mode: "merge",
|
||||
includeExpired: false,
|
||||
});
|
||||
assert.equal(imported.cookies_imported, 1);
|
||||
assert.equal(imported.added, 1);
|
||||
assert.equal(imported.overwritten, 0);
|
||||
assert.equal(imported.deleted, 0);
|
||||
const cookies = await app.invoke("read_profile_cookies", {
|
||||
profileId: source.id,
|
||||
});
|
||||
@@ -803,6 +808,62 @@ test("cookie import/copy/export, profile encryption, and traffic-stat read/clear
|
||||
/fixture\.local/,
|
||||
);
|
||||
|
||||
const paste = [
|
||||
"# Netscape HTTP Cookie File",
|
||||
"#HttpOnly_.fixture.local\tTRUE\t/\tFALSE\t2000000000\tpasted\tpasted-value",
|
||||
].join("\n");
|
||||
const analysis = await app.invoke("analyze_pasted_cookies", {
|
||||
profileId: target.id,
|
||||
content: paste,
|
||||
site: null,
|
||||
});
|
||||
assert.equal(analysis.format, "netscape");
|
||||
assert.equal(analysis.cookies.length, 1);
|
||||
assert.equal(analysis.cookies[0].name, "pasted");
|
||||
assert.equal(analysis.cookies[0].isHttpOnly, true);
|
||||
assert.equal(
|
||||
analysis.cookies[0].value,
|
||||
undefined,
|
||||
"the preview must never carry the cookie value",
|
||||
);
|
||||
assert.equal(analysis.siteRequired, false);
|
||||
assert.equal(analysis.expiredCount, 0);
|
||||
assert.equal(analysis.blockedBy, null);
|
||||
// The copied fixture.local cookie is the one row replace mode would clear.
|
||||
assert.equal(analysis.replaceDeleteCount, 1);
|
||||
|
||||
const merged = await app.invoke("import_pasted_cookies", {
|
||||
profileId: target.id,
|
||||
content: paste,
|
||||
site: null,
|
||||
mode: "merge",
|
||||
includeExpired: false,
|
||||
});
|
||||
assert.equal(merged.added, 1);
|
||||
assert.equal(merged.deleted, 0);
|
||||
assert.equal(merged.skipped, 0);
|
||||
assert.equal(
|
||||
(await app.invoke("get_profile_cookie_stats", { profileId: target.id }))
|
||||
.total_count,
|
||||
2,
|
||||
);
|
||||
|
||||
// Both spellings of the pasted site go, and only they do.
|
||||
const replacedPaste = await app.invoke("import_pasted_cookies", {
|
||||
profileId: target.id,
|
||||
content: paste,
|
||||
site: null,
|
||||
mode: "replaceMatchingSites",
|
||||
includeExpired: false,
|
||||
});
|
||||
assert.equal(replacedPaste.deleted, 2);
|
||||
assert.equal(replacedPaste.added, 1);
|
||||
const afterReplace = await app.invoke("read_profile_cookies", {
|
||||
profileId: target.id,
|
||||
});
|
||||
assert.equal(afterReplace.total_count, 1);
|
||||
assert.equal(afterReplace.domains[0].cookies[0].name, "pasted");
|
||||
|
||||
await app.invoke("set_profile_password", {
|
||||
profileId: source.id,
|
||||
password: "correct horse battery staple",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { appFromEnvironment } from "../lib/app.mjs";
|
||||
@@ -575,3 +576,85 @@ test("global config sealing and encrypted profile sync reject a wrong password,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// A self-hosted server reaches its storage over an address only it can
|
||||
// resolve — the documented compose file points S3_ENDPOINT at
|
||||
// http://minio:9000, a Docker service name that exists on the compose network
|
||||
// and nowhere else. Files never travel through the sync server, so every
|
||||
// presigned URL then names a host the desktop cannot open: /health and /readyz
|
||||
// stay green while every single transfer dies at connect. Reported as "the
|
||||
// endpoint connection works every time, but no MB is ever synced".
|
||||
test("the connection check fails a server whose storage host this device cannot reach", async () => {
|
||||
assert.ok(syncUrl && syncToken, "Sync infrastructure was not started");
|
||||
const app = appFromEnvironment("sync-preflight");
|
||||
|
||||
// Answers exactly like a healthy self-hosted server that signs presigned
|
||||
// URLs against a container-only host.
|
||||
const misconfigured = createServer((request, response) => {
|
||||
if (request.url === "/readyz") {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
status: "ready",
|
||||
s3: true,
|
||||
storageEndpoint: "http://minio.invalid:9000",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
});
|
||||
await new Promise((resolve) => misconfigured.listen(0, "127.0.0.1", resolve));
|
||||
const misconfiguredUrl = `http://127.0.0.1:${misconfigured.address().port}`;
|
||||
|
||||
try {
|
||||
await app.start();
|
||||
|
||||
const healthy = await app.invoke("check_sync_server_connection", {
|
||||
serverUrl: syncUrl,
|
||||
});
|
||||
assert.equal(healthy.server_reachable, true, "real sync server answers");
|
||||
assert.notEqual(
|
||||
healthy.storage_reachable,
|
||||
false,
|
||||
"the suite's own storage must be reachable from the test device",
|
||||
);
|
||||
|
||||
// The regression itself: green server, storage nobody here can open.
|
||||
const broken = await app.invoke("check_sync_server_connection", {
|
||||
serverUrl: misconfiguredUrl,
|
||||
});
|
||||
assert.equal(broken.server_reachable, true, "server itself answered");
|
||||
assert.equal(broken.storage_ready, true, "server reaches its own storage");
|
||||
assert.equal(broken.storage_endpoint, "http://minio.invalid:9000");
|
||||
assert.equal(
|
||||
broken.storage_reachable,
|
||||
false,
|
||||
"an unreachable storage host must not report as a working connection",
|
||||
);
|
||||
assert.ok(
|
||||
broken.storage_error && broken.storage_error.length > 0,
|
||||
"the failure must carry a cause",
|
||||
);
|
||||
assert.notEqual(
|
||||
broken.storage_error,
|
||||
"error sending request",
|
||||
"the cause must name the transport failure, not the bare reqwest text",
|
||||
);
|
||||
|
||||
// A server that does not answer at all stays a plain connection failure,
|
||||
// so the two are never confused in the UI.
|
||||
const dead = await app.invoke("check_sync_server_connection", {
|
||||
serverUrl: "http://127.0.0.1:1",
|
||||
});
|
||||
assert.equal(dead.server_reachable, false);
|
||||
assert.equal(dead.storage_reachable, null);
|
||||
} catch (error) {
|
||||
await app.capture("failure");
|
||||
throw error;
|
||||
} finally {
|
||||
await new Promise((resolve) => misconfigured.close(resolve));
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -96,17 +96,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.29.5";
|
||||
releaseVersion = "0.29.6";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_amd64.AppImage";
|
||||
hash = "sha256-/zOFGaHTk4S53DLZe2BXmILe/P1dlb9dT94hyvEqXcM=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_amd64.AppImage";
|
||||
hash = "sha256-tZtULAhKl0HaiKj4WUZX4pkzjbPtOS/rioXPjrrm4Xk=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_aarch64.AppImage";
|
||||
hash = "sha256-w2xibn/GZq681vZDj9pYNOrwhnqxX0POhmBO8XMFb+c=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.6/Donut_0.29.6_aarch64.AppImage";
|
||||
hash = "sha256-5FWZeECixlNNxHuGzKoNxmHDjNtePU0yg0bgDLD1s5c=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
+36
-35
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.29.5",
|
||||
"version": "0.30.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "pnpm licenses:generate",
|
||||
@@ -10,11 +10,12 @@
|
||||
"prebuild": "pnpm licenses:generate",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test": "pnpm test:themes && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:proxy-string && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
|
||||
"test": "pnpm test:themes && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:proxy-string && pnpm test:profile-search && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
|
||||
"test:themes": "node --test src/lib/themes.test.mjs",
|
||||
"test:window-decorations": "node --test src/lib/window-decorations.test.mjs",
|
||||
"test:cookie-bot-limits": "node --test src/lib/cookie-bot-limits.test.mjs",
|
||||
"test:proxy-string": "node --test src/lib/proxy-string.test.mjs",
|
||||
"test:profile-search": "node --test src/lib/profile-search.test.mjs",
|
||||
"test:licenses": "node --test scripts/generate-licenses.test.mjs && node scripts/generate-licenses.mjs --check",
|
||||
"test:xray-packaging": "node --test src-tauri/download-xray.test.mjs",
|
||||
"licenses:generate": "node scripts/generate-licenses.mjs",
|
||||
@@ -50,27 +51,27 @@
|
||||
"precargo": "pnpm copy-proxy-binary"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.7",
|
||||
"@radix-ui/react-dialog": "^1.1.19",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.20",
|
||||
"@radix-ui/react-label": "^2.1.11",
|
||||
"@radix-ui/react-popover": "^1.1.19",
|
||||
"@radix-ui/react-portal": "^1.1.13",
|
||||
"@radix-ui/react-progress": "^1.1.12",
|
||||
"@radix-ui/react-radio-group": "^1.4.3",
|
||||
"@radix-ui/react-scroll-area": "^1.2.14",
|
||||
"@radix-ui/react-select": "^2.3.3",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-tabs": "^1.1.17",
|
||||
"@radix-ui/react-tooltip": "^1.2.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.11",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||
"@radix-ui/react-label": "^2.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.23",
|
||||
"@radix-ui/react-portal": "^1.1.17",
|
||||
"@radix-ui/react-progress": "^1.1.16",
|
||||
"@radix-ui/react-radio-group": "^1.4.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-slot": "^1.3.3",
|
||||
"@radix-ui/react-tabs": "^1.1.21",
|
||||
"@radix-ui/react-tooltip": "^1.2.16",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.14.5",
|
||||
"@tanstack/react-virtual": "^3.14.10",
|
||||
"@tauri-apps/api": "~2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.9",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-fs": "~2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@tauri-apps/plugin-log": "^2.9.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"ahooks": "^3.9.7",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
@@ -79,35 +80,35 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"color": "^5.0.3",
|
||||
"flag-icons": "^7.5.0",
|
||||
"i18next": "^26.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"motion": "^12.42.2",
|
||||
"next": "^16.2.11",
|
||||
"i18next": "^26.4.0",
|
||||
"lucide-react": "^1.34.0",
|
||||
"motion": "^13.1.1",
|
||||
"next": "^16.3.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"onborda": "^1.2.5",
|
||||
"radix-ui": "^1.6.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.12",
|
||||
"react-icons": "^5.7.0",
|
||||
"recharts": "3.9.2",
|
||||
"sonner": "^2.0.7",
|
||||
"recharts": "3.10.1",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.2",
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"@biomejs/biome": "2.5.10",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@tauri-apps/cli": "~2.11.4",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/color": "^4.2.1",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/node": "^26.3.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.8",
|
||||
"lint-staged": "^17.3.0",
|
||||
"spdx-expression-parse": "5.0.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"ts-unused-exports": "^11.0.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.3"
|
||||
|
||||
Generated
+1986
-2419
File diff suppressed because it is too large
Load Diff
@@ -43,59 +43,5 @@ allowBuilds:
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- '@radix-ui/primitive@1.1.5'
|
||||
- '@radix-ui/react-accordion@1.2.16'
|
||||
- '@radix-ui/react-alert-dialog@1.1.19'
|
||||
- '@radix-ui/react-avatar@1.2.2'
|
||||
- '@radix-ui/react-checkbox@1.3.7'
|
||||
- '@radix-ui/react-collapsible@1.1.16'
|
||||
- '@radix-ui/react-collection@1.1.12'
|
||||
- '@radix-ui/react-context-menu@2.3.3'
|
||||
- '@radix-ui/react-context@1.2.0'
|
||||
- '@radix-ui/react-dialog@1.1.19'
|
||||
- '@radix-ui/react-dismissable-layer@1.1.15'
|
||||
- '@radix-ui/react-dropdown-menu@2.1.20'
|
||||
- '@radix-ui/react-focus-scope@1.1.12'
|
||||
- '@radix-ui/react-form@0.1.12'
|
||||
- '@radix-ui/react-hover-card@1.1.19'
|
||||
- '@radix-ui/react-menu@2.1.20'
|
||||
- '@radix-ui/react-menubar@1.1.20'
|
||||
- '@radix-ui/react-navigation-menu@1.2.18'
|
||||
- '@radix-ui/react-one-time-password-field@0.1.12'
|
||||
- '@radix-ui/react-password-toggle-field@0.1.7'
|
||||
- '@radix-ui/react-popover@1.1.19'
|
||||
- '@radix-ui/react-popper@1.3.3'
|
||||
- '@radix-ui/react-presence@1.1.7'
|
||||
- '@radix-ui/react-progress@1.1.12'
|
||||
- '@radix-ui/react-radio-group@1.4.3'
|
||||
- '@radix-ui/react-roving-focus@1.1.15'
|
||||
- '@radix-ui/react-scroll-area@1.2.14'
|
||||
- '@radix-ui/react-select@2.3.3'
|
||||
- '@radix-ui/react-slider@1.4.3'
|
||||
- '@radix-ui/react-switch@1.3.3'
|
||||
- '@radix-ui/react-tabs@1.1.17'
|
||||
- '@radix-ui/react-toast@1.2.19'
|
||||
- '@radix-ui/react-toggle-group@1.1.15'
|
||||
- '@radix-ui/react-toggle@1.1.14'
|
||||
- '@radix-ui/react-toolbar@1.1.15'
|
||||
- '@radix-ui/react-tooltip@1.2.12'
|
||||
- radix-ui@1.6.2
|
||||
- '@aws-sdk/checksums@3.1000.14'
|
||||
- '@aws-sdk/client-s3@3.1081.0'
|
||||
- '@aws-sdk/core@3.974.29'
|
||||
- '@aws-sdk/credential-provider-env@3.972.55'
|
||||
- '@aws-sdk/credential-provider-http@3.972.57'
|
||||
- '@aws-sdk/credential-provider-ini@3.972.62'
|
||||
- '@aws-sdk/credential-provider-login@3.972.61'
|
||||
- '@aws-sdk/credential-provider-node@3.972.64'
|
||||
- '@aws-sdk/credential-provider-process@3.972.55'
|
||||
- '@aws-sdk/credential-provider-sso@3.972.61'
|
||||
- '@aws-sdk/credential-provider-web-identity@3.972.61'
|
||||
- '@aws-sdk/middleware-sdk-s3@3.972.60'
|
||||
- '@aws-sdk/nested-clients@3.997.29'
|
||||
- '@aws-sdk/s3-request-presigner@3.1081.0'
|
||||
- '@aws-sdk/token-providers@3.1081.0'
|
||||
|
||||
patchedDependencies:
|
||||
brace-expansion@5.0.9: patches/brace-expansion@5.0.9.patch
|
||||
|
||||
Generated
+652
-750
File diff suppressed because it is too large
Load Diff
+20
-8
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.29.5"
|
||||
version = "0.30.0"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -26,7 +26,7 @@ path = "src/bin/proxy_server.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
resvg = "0.47"
|
||||
resvg = "0.48"
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
@@ -51,7 +51,11 @@ tokio = { version = "1", features = ["full", "sync"] }
|
||||
tokio-util = "0.7"
|
||||
sysinfo = "0.39"
|
||||
lazy_static = "1.5"
|
||||
base64 = "0.22"
|
||||
# 0.23 turns on `simd-unsafe` by default, decoding via hand-written unsafe
|
||||
# AVX2/NEON. This crate decodes attacker-influenced input (proxy CONNECT auth,
|
||||
# extension payloads, os_crypt key blobs), and none of those paths are hot
|
||||
# enough to be worth it, so stay on the scalar engine.
|
||||
base64 = { version = "0.23", default-features = false, features = ["std"] }
|
||||
libc = "0.2"
|
||||
async-trait = "0.1"
|
||||
futures-util = "0.3"
|
||||
@@ -93,19 +97,19 @@ async-socks5 = "0.6"
|
||||
|
||||
|
||||
# Wayfern CDP integration
|
||||
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
|
||||
tokio-tungstenite = { version = "0.30", features = ["native-tls"] }
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
serde_yaml = "0.9"
|
||||
toml = "1.1"
|
||||
thiserror = "2.0"
|
||||
regex-lite = "0.1"
|
||||
tempfile = "3"
|
||||
maxminddb = "0.29"
|
||||
quick-xml = { version = "0.41", features = ["serialize"] }
|
||||
maxminddb = "0.30"
|
||||
quick-xml = { version = "0.42", features = ["serialize"] }
|
||||
|
||||
# VPN support
|
||||
boringtun = "0.7"
|
||||
smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp", "socket-dns"] }
|
||||
smoltcp = { version = "0.14", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp", "socket-dns"] }
|
||||
|
||||
# Tray icon decoding (main-process system tray)
|
||||
image = "0.25"
|
||||
@@ -143,7 +147,15 @@ windows = { version = "0.62", features = [
|
||||
"Win32_Security",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Registry",
|
||||
# CoInitializeEx, so the `ms-settings:` hand-off in default_browser.rs has a
|
||||
# COM apartment. ShellExecuteW activates the URI through a shell extension,
|
||||
# and it runs on a `spawn_blocking` thread that has no apartment of its own.
|
||||
"Win32_System_Com",
|
||||
"Win32_UI_Shell",
|
||||
# SendMessageTimeoutW, for the association-change broadcast in
|
||||
# default_browser.rs. Going through the crate rather than a hand-written
|
||||
# `extern "system"` block is what keeps `lpdwResult` typed as DWORD_PTR.
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
# CryptUnprotectData, for unwrapping the source browser's os_crypt key from
|
||||
# `Local State` during profile import.
|
||||
"Win32_Security_Cryptography",
|
||||
@@ -158,7 +170,7 @@ http-body-util = "0.1"
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.7", features = ["fs", "trace"] }
|
||||
futures-util = "0.3"
|
||||
serial_test = "3"
|
||||
serial_test = "4"
|
||||
|
||||
# Integration test configuration
|
||||
[[test]]
|
||||
|
||||
+1
-1
@@ -188,7 +188,7 @@ fn generate_tray_icons() {
|
||||
// macOS will automatically handle light/dark mode by inverting the icon
|
||||
// For template icons: RGB should be 0,0,0 (black) and alpha controls visibility
|
||||
let data = pixmap.data_mut();
|
||||
for pixel in data.chunks_exact_mut(4) {
|
||||
for pixel in data.as_chunks_mut::<4>().0 {
|
||||
// Keep the original alpha (shows where icon content is)
|
||||
// but make the color black for template icon format
|
||||
pixel[0] = 0; // R
|
||||
|
||||
@@ -4253,7 +4253,7 @@ async fn import_profiles_api(
|
||||
(status = 400, description = "Invalid cookie file or unsupported browser"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 409, description = "Browser is currently running"),
|
||||
(status = 409, description = "Browser is running, the profile is password-protected, or a remote session owns it"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
@@ -4300,10 +4300,16 @@ async fn import_profile_cookies(
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_lowercase();
|
||||
if msg.contains("running") {
|
||||
// The importer speaks in `{"code":…}` strings now; match those, and keep
|
||||
// the substring checks for the messages that are still plain text.
|
||||
if e.contains("COOKIE_IMPORT_BROWSER_RUNNING")
|
||||
|| e.contains("COOKIE_IMPORT_PROFILE_PROTECTED")
|
||||
|| e.contains("COOKIE_IMPORT_REMOTE_SESSION")
|
||||
{
|
||||
Err(StatusCode::CONFLICT)
|
||||
} else if msg.contains("no valid cookies") || msg.contains("unsupported browser") {
|
||||
} else if e.contains("COOKIE_IMPORT_NO_COOKIES")
|
||||
|| e.to_lowercase().contains("unsupported browser")
|
||||
{
|
||||
Err(StatusCode::BAD_REQUEST)
|
||||
} else {
|
||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
|
||||
@@ -768,42 +768,6 @@ impl AppAutoUpdater {
|
||||
.map(|a| a.browser_download_url.clone())
|
||||
}
|
||||
|
||||
/// Extract the hex digest for `filename` from standard `sha256sum` output
|
||||
/// (`<hex> <name>`, optionally with the `*` binary-mode marker).
|
||||
fn find_checksum_for_file(checksums_text: &str, filename: &str) -> Option<String> {
|
||||
checksums_text.lines().find_map(|line| {
|
||||
let (hash, rest) = line.split_once(char::is_whitespace)?;
|
||||
let name = rest.trim_start().trim_start_matches('*');
|
||||
if name == filename && hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
Some(hash.to_ascii_lowercase())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn sha256_file(path: &Path) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read;
|
||||
let mut file = fs::File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = vec![0u8; 1024 * 1024];
|
||||
loop {
|
||||
let n = file.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..n]);
|
||||
}
|
||||
let digest = hasher.finalize();
|
||||
let mut hex = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(hex, "{byte:02x}");
|
||||
}
|
||||
Ok(hex)
|
||||
}
|
||||
|
||||
/// Fetch the release's SHA256SUMS.txt and return the expected digest for
|
||||
/// `filename`. Called BEFORE the (large) asset download so an unverifiable
|
||||
/// release is rejected without wasting the transfer. Every failure mode
|
||||
@@ -857,7 +821,7 @@ impl AppAutoUpdater {
|
||||
}
|
||||
};
|
||||
|
||||
let Some(expected) = Self::find_checksum_for_file(&checksums_text, filename) else {
|
||||
let Some(expected) = crate::checksum::find_checksum_for_file(&checksums_text, filename) else {
|
||||
log::warn!(
|
||||
"No checksum entry for {filename} in {}",
|
||||
Self::CHECKSUMS_ASSET_NAME
|
||||
@@ -877,7 +841,7 @@ impl AppAutoUpdater {
|
||||
expected: &str,
|
||||
asset_digest: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let actual = Self::sha256_file(file_path)?;
|
||||
let actual = crate::checksum::sha256_file(file_path)?;
|
||||
|
||||
let mut mismatch = !actual.eq_ignore_ascii_case(expected);
|
||||
|
||||
@@ -1908,36 +1872,34 @@ rm "{}"
|
||||
parameters
|
||||
);
|
||||
|
||||
// windows-sys is not a direct dep, so use the raw FFI via the
|
||||
// windows crate that Tauri pulls in. ShellExecuteW returns an
|
||||
// HINSTANCE > 32 on success.
|
||||
#[link(name = "shell32")]
|
||||
extern "system" {
|
||||
fn ShellExecuteW(
|
||||
hwnd: *mut std::ffi::c_void,
|
||||
operation: *const u16,
|
||||
file: *const u16,
|
||||
parameters: *const u16,
|
||||
directory: *const u16,
|
||||
show_cmd: i32,
|
||||
) -> isize;
|
||||
}
|
||||
const SW_SHOWNORMAL: i32 = 1;
|
||||
let open: Vec<u16> = "open\0".encode_utf16().collect();
|
||||
// Take the binding from the `windows` crate rather than writing the
|
||||
// declaration here. A hand-written one is what put the wrong width on
|
||||
// `SendMessageTimeoutA`'s out-parameter in `default_browser.rs`, and
|
||||
// that killed the process on every click of "Set as default browser".
|
||||
// No compiler and no lint can see such a mistake. The generated binding
|
||||
// cannot drift from the real ABI, so there is nothing to get wrong.
|
||||
use windows::core::{w, PCWSTR};
|
||||
use windows::Win32::UI::Shell::ShellExecuteW;
|
||||
use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
|
||||
|
||||
let result = unsafe {
|
||||
ShellExecuteW(
|
||||
std::ptr::null_mut(),
|
||||
open.as_ptr(),
|
||||
file_w.as_ptr(),
|
||||
params_w.as_ptr(),
|
||||
std::ptr::null(),
|
||||
None,
|
||||
w!("open"),
|
||||
PCWSTR(file_w.as_ptr()),
|
||||
PCWSTR(params_w.as_ptr()),
|
||||
PCWSTR::null(),
|
||||
SW_SHOWNORMAL,
|
||||
)
|
||||
};
|
||||
|
||||
if result as usize <= 32 {
|
||||
return Err(format!("ShellExecuteW failed with code {result}").into());
|
||||
// ShellExecuteW reports success as a value above 32. Anything at or
|
||||
// below that is an error code wearing a handle's type. Read it as a
|
||||
// signed value: the old `as usize` turned every negative code into a
|
||||
// very large number, which read as success.
|
||||
let code = result.0 as isize;
|
||||
if code <= 32 {
|
||||
return Err(format!("ShellExecuteW failed with code {code}").into());
|
||||
}
|
||||
} else {
|
||||
// No pending installer — just restart the app. Use a minimal
|
||||
@@ -2226,48 +2188,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_checksum_for_file() {
|
||||
let sums = "\
|
||||
0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11 Donut_0.29.0_aarch64.dmg
|
||||
ABCDEF01745092B7D1C93C1E7E1C30D923BE3D1E916B661BD53D1C0C9C7F0A22 *Donut_0.29.0_x64.dmg
|
||||
not-a-hash Donut_0.29.0_amd64.deb
|
||||
";
|
||||
|
||||
// Plain entry.
|
||||
assert_eq!(
|
||||
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_aarch64.dmg").as_deref(),
|
||||
Some("0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11")
|
||||
);
|
||||
// Binary-mode marker is stripped; hash is normalized to lowercase.
|
||||
assert_eq!(
|
||||
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_x64.dmg").as_deref(),
|
||||
Some("abcdef01745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a22")
|
||||
);
|
||||
// Entries with malformed hashes are rejected rather than trusted.
|
||||
assert_eq!(
|
||||
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_amd64.deb"),
|
||||
None
|
||||
);
|
||||
// Missing file.
|
||||
assert_eq!(
|
||||
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_arm64.deb"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sha256_file_matches_known_digest() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let path = temp_dir.path().join("data.bin");
|
||||
std::fs::write(&path, b"hello world").unwrap();
|
||||
assert_eq!(
|
||||
AppAutoUpdater::sha256_file(&path).unwrap(),
|
||||
// sha256 of "hello world"
|
||||
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_checksums_url() {
|
||||
let assets = vec![
|
||||
|
||||
@@ -25,6 +25,14 @@ impl BrowserType {
|
||||
}
|
||||
}
|
||||
|
||||
/// Brand name for user-facing strings. `as_str` is the internal id and is
|
||||
/// the wrong thing to put in a message the user reads.
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
BrowserType::Wayfern => "Wayfern",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Result<Self, String> {
|
||||
match s {
|
||||
"wayfern" => Ok(BrowserType::Wayfern),
|
||||
|
||||
@@ -458,38 +458,91 @@ impl BrowserRunner {
|
||||
wayfern_config.proxy
|
||||
);
|
||||
|
||||
// Check if we need to generate a new fingerprint on every launch
|
||||
// Check if we need to generate a device for this launch.
|
||||
//
|
||||
// Two cases share the block: the user asked for a fresh device on every
|
||||
// launch, or the profile stores none at all. The second is how a clone
|
||||
// arrives here — cloning clears the fingerprint and the identity so the
|
||||
// clone gets an independent device instead of the browser's default —
|
||||
// and it also covers any profile that reached disk without one, which
|
||||
// used to launch on whatever device the browser drew for itself.
|
||||
//
|
||||
// A profile that ALREADY stores a device keeps it across a browser
|
||||
// upgrade: nothing here mints a replacement, and its stored payload is
|
||||
// what the launch applies. The one thing that does replace a stored
|
||||
// device is the user asking for it - `randomize_fingerprint_on_launch`,
|
||||
// tested immediately below - which is a deliberate per-profile setting
|
||||
// and not a consequence of the version.
|
||||
let mut updated_profile = profile.clone();
|
||||
if wayfern_config.randomize_fingerprint_on_launch == Some(true) {
|
||||
log::info!(
|
||||
"Generating random fingerprint for Wayfern profile: {}",
|
||||
profile.name
|
||||
);
|
||||
let randomize_requested = wayfern_config.randomize_fingerprint_on_launch == Some(true);
|
||||
let needs_device = wayfern_config.fingerprint.is_none();
|
||||
if randomize_requested || needs_device {
|
||||
if needs_device && !randomize_requested {
|
||||
log::info!(
|
||||
"No stored device for Wayfern profile {}; generating one",
|
||||
profile.name
|
||||
);
|
||||
} else {
|
||||
log::info!(
|
||||
"Generating random fingerprint for Wayfern profile: {}",
|
||||
profile.name
|
||||
);
|
||||
}
|
||||
|
||||
// Create a config copy without the existing fingerprint to force generation of a new one
|
||||
let mut config_for_generation = wayfern_config.clone();
|
||||
config_for_generation.fingerprint = None;
|
||||
|
||||
// Generate a new fingerprint
|
||||
let (new_fingerprint, geolocation_applied) = self
|
||||
// A failed generation fails the launch on purpose: continuing would
|
||||
// start the browser on whatever device it drew for itself, unmanaged
|
||||
// and unrecorded, while the UI still reports a successful launch. For
|
||||
// an anti-detect product a silently wrong device is worse than no
|
||||
// launch at all, because nothing tells the user to stop using it.
|
||||
//
|
||||
// Structured rather than prose, because the most common failure is the
|
||||
// browser refusing a generation once the account's hourly quota is
|
||||
// spent. That has to reach the user as an explanation; a raw CDP string
|
||||
// is not one, and the frontend only translates a coded error.
|
||||
let generated = self
|
||||
.wayfern_manager
|
||||
.generate_fingerprint_config(&app_handle, profile, &config_for_generation)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to generate random fingerprint: {e}"))?;
|
||||
.map_err(|e| {
|
||||
let detail = e.to_string();
|
||||
// BOTH refusal texts, because this path serves BOTH releases. 151
|
||||
// says "Fingerprint generation limit reached for this account.";
|
||||
// the shipped 150 browser says "Too many profiles are being
|
||||
// created." Matching only the 151 wording leaves a quota-blocked
|
||||
// 150 user staring at a raw CDP string, which is the exact defect
|
||||
// this mapping exists to remove.
|
||||
if detail.contains("generation limit reached") || detail.contains("Too many profiles") {
|
||||
crate::backend_error_with_detail("WAYFERN_GENERATION_LIMIT_REACHED", detail)
|
||||
} else {
|
||||
crate::backend_error_with_detail("WAYFERN_FINGERPRINT_GENERATION_FAILED", detail)
|
||||
}
|
||||
})?;
|
||||
|
||||
let geolocation_applied = generated.geolocation_applied;
|
||||
|
||||
log::info!(
|
||||
"New fingerprint generated, length: {} chars",
|
||||
new_fingerprint.len()
|
||||
"New fingerprint generated, length: {} chars, identity: {:?}",
|
||||
generated.fingerprint.len(),
|
||||
generated.identity_id
|
||||
);
|
||||
|
||||
// Update the config with the new fingerprint for launching
|
||||
wayfern_config.fingerprint = Some(new_fingerprint.clone());
|
||||
wayfern_config.fingerprint = Some(generated.fingerprint.clone());
|
||||
wayfern_config.identity_id = generated.identity_id.clone();
|
||||
wayfern_config.identity_baseline = generated.identity_baseline.clone();
|
||||
|
||||
// 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);
|
||||
updated_wayfern_config.fingerprint = Some(generated.fingerprint);
|
||||
updated_wayfern_config.identity_id = generated.identity_id;
|
||||
updated_wayfern_config.identity_baseline = generated.identity_baseline;
|
||||
// Preserve the randomize flag so it persists across launches
|
||||
updated_wayfern_config.randomize_fingerprint_on_launch = Some(true);
|
||||
updated_wayfern_config.randomize_fingerprint_on_launch =
|
||||
wayfern_config.randomize_fingerprint_on_launch;
|
||||
// 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();
|
||||
@@ -601,7 +654,11 @@ impl BrowserRunner {
|
||||
)
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Failed to launch Wayfern: {e}").into()
|
||||
// A refused apply reports itself as a structured error so the dialog
|
||||
// can name the cause. Prefixing it would put English in front of the
|
||||
// JSON the frontend parses, and the whole thing would reach the user
|
||||
// as raw machine output.
|
||||
crate::wrap_backend_error(e, "Failed to launch Wayfern").into()
|
||||
})?;
|
||||
|
||||
// Get the process ID from launch result
|
||||
@@ -653,20 +710,27 @@ impl BrowserRunner {
|
||||
guard.worker_id = None;
|
||||
}
|
||||
|
||||
// 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
|
||||
// The apply command echoes back the device the browser actually used,
|
||||
// which may differ from the stored one. Persist it so the next launch
|
||||
// starts from that 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()) {
|
||||
let baseline_changed = wayfern_result.used_identity_baseline.is_some()
|
||||
&& cfg.identity_baseline != wayfern_result.used_identity_baseline;
|
||||
if cfg.fingerprint.as_deref() != Some(used_fp.as_str()) || baseline_changed {
|
||||
log::info!(
|
||||
"Persisting upgraded fingerprint from Wayfern.setFingerprint for profile: {} (len {})",
|
||||
"Persisting applied fingerprint echoed by Wayfern for profile: {} (len {})",
|
||||
profile.name,
|
||||
used_fp.len()
|
||||
);
|
||||
cfg.fingerprint = Some(used_fp);
|
||||
// The baseline must move with the fingerprint it was computed
|
||||
// against, or the next launch diffs the two apart and invents
|
||||
// overrides the user never asked for.
|
||||
if let Some(baseline) = wayfern_result.used_identity_baseline.clone() {
|
||||
cfg.identity_baseline = Some(baseline);
|
||||
}
|
||||
updated_profile.wayfern_config = Some(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
+113
-5
@@ -589,7 +589,13 @@ pub async fn run_command_awaiting_load(
|
||||
let text = match tokio::time::timeout(remaining, connection.next_text()).await {
|
||||
Ok(Some(Ok(text))) => text,
|
||||
Ok(Some(Err(e))) => {
|
||||
failure = Some(e);
|
||||
// A dropped connection cannot unsay a command the browser already
|
||||
// answered: the navigation was accepted, and the command's result is
|
||||
// the best answer available even if the load event never arrived.
|
||||
// Only when nothing was answered yet is the lost socket a failure.
|
||||
if command_result.is_none() {
|
||||
failure = Some(e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// The peer hung up, or the wait expired. Either way whatever the command
|
||||
@@ -1114,6 +1120,15 @@ mod tests {
|
||||
Cooperative,
|
||||
/// Hang up the way a session that is not yet up does.
|
||||
RefuseAsNotDrivable,
|
||||
/// Answer the navigation, then drop the socket before the load event,
|
||||
/// the way a relay does when the browser it bridges dies mid-navigation.
|
||||
DropAfterNavigateReply,
|
||||
/// Drop the socket without answering the navigation.
|
||||
DropBeforeNavigateReply,
|
||||
/// Answer the navigation with a CDP error object.
|
||||
AnswerNavigateWithError,
|
||||
/// Answer commands but never emit the load event, so the wait expires.
|
||||
NeverLoadEvent,
|
||||
}
|
||||
|
||||
/// The CDP session id the fake relay hands out for a flat attach.
|
||||
@@ -1185,18 +1200,32 @@ mod tests {
|
||||
};
|
||||
log.received.push(request.clone());
|
||||
|
||||
let method = request.get("method").and_then(Value::as_str).unwrap_or("");
|
||||
|
||||
// A reply dropped mid-flight must not look like the command answered.
|
||||
if behaviour == RelayBehaviour::DropBeforeNavigateReply && method == "Page.navigate" {
|
||||
break;
|
||||
}
|
||||
|
||||
let id = request.get("id").cloned().unwrap_or(Value::Null);
|
||||
let reply = match request.get("method").and_then(Value::as_str) {
|
||||
Some("Target.getTargets") => serde_json::json!({
|
||||
let reply = match method {
|
||||
"Target.getTargets" => serde_json::json!({
|
||||
"id": id,
|
||||
"result": { "targetInfos": [
|
||||
{ "targetId": "page-1", "type": "page", "url": "https://example.com/" }
|
||||
]}
|
||||
}),
|
||||
Some("Target.attachToTarget") => serde_json::json!({
|
||||
"Target.attachToTarget" => serde_json::json!({
|
||||
"id": id,
|
||||
"result": { "sessionId": FAKE_CDP_SESSION }
|
||||
}),
|
||||
"Page.navigate" if behaviour == RelayBehaviour::AnswerNavigateWithError => {
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"sessionId": request.get("sessionId").cloned().unwrap_or(Value::Null),
|
||||
"error": { "code": -32000, "message": "fake navigation error" }
|
||||
})
|
||||
}
|
||||
// Everything else is handed straight back, so the test can assert on
|
||||
// the exact frame the client put on the wire.
|
||||
_ => serde_json::json!({
|
||||
@@ -1213,10 +1242,17 @@ mod tests {
|
||||
break;
|
||||
}
|
||||
|
||||
// The browser died mid-navigation: drop the socket without a close
|
||||
// frame, the way a relay does when the VM it bridges goes away. The
|
||||
// command's reply is already in the client's hands.
|
||||
if behaviour == RelayBehaviour::DropAfterNavigateReply && method == "Page.navigate" {
|
||||
break;
|
||||
}
|
||||
|
||||
// A real browser follows a navigation with the load event, flattened
|
||||
// onto the same socket. Emitting it here is what proves the wait
|
||||
// actually terminates on the event rather than on its timeout.
|
||||
if request.get("method").and_then(Value::as_str) == Some("Page.navigate") {
|
||||
if behaviour != RelayBehaviour::NeverLoadEvent && method == "Page.navigate" {
|
||||
let loaded = serde_json::json!({
|
||||
"method": "Page.loadEventFired",
|
||||
"sessionId": request.get("sessionId").cloned().unwrap_or(Value::Null),
|
||||
@@ -1377,4 +1413,76 @@ mod tests {
|
||||
assert_eq!(methods, vec!["Runtime.evaluate"]);
|
||||
assert!(log.received[0].get("sessionId").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_navigation_result_survives_a_connection_dropped_before_the_load_event() {
|
||||
// The race this fix targets: a fast navigation can kill the connection
|
||||
// between the command reply and the load event. The browser already
|
||||
// accepted the navigation, so the reply is the answer — losing the socket
|
||||
// afterwards must not turn the answered command into a failure.
|
||||
let (ws_url, server) = fake_relay(RelayBehaviour::DropAfterNavigateReply).await;
|
||||
let target = CdpTarget::Local { ws_url };
|
||||
|
||||
navigate(&target, "https://example.com", 30)
|
||||
.await
|
||||
.expect("an answered navigation must survive a dropped connection");
|
||||
|
||||
let _log = server.await.expect("the fake relay must finish");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_connection_dropped_before_the_reply_is_still_reported_as_a_failure() {
|
||||
// The flip side of the race: if the socket dies before the command
|
||||
// answered, there is nothing to prefer — the navigation may never have
|
||||
// happened, so the call must still fail.
|
||||
let (ws_url, server) = fake_relay(RelayBehaviour::DropBeforeNavigateReply).await;
|
||||
let target = CdpTarget::Local { ws_url };
|
||||
|
||||
let error = navigate(&target, "https://example.com", 30)
|
||||
.await
|
||||
.expect_err("a navigation that never answered must not look like a success");
|
||||
assert!(
|
||||
matches!(error, CdpError::Transport(_)),
|
||||
"expected Transport, got {error:?}"
|
||||
);
|
||||
|
||||
let _log = server.await.expect("the fake relay must finish");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_navigation_error_from_the_browser_is_still_reported() {
|
||||
// A CDP error object is the browser saying no — that answer must not be
|
||||
// swallowed by the load wait.
|
||||
let (ws_url, server) = fake_relay(RelayBehaviour::AnswerNavigateWithError).await;
|
||||
let target = CdpTarget::Local { ws_url };
|
||||
|
||||
let error = navigate(&target, "https://example.com", 30)
|
||||
.await
|
||||
.expect_err("a CDP error reply must surface as a failure");
|
||||
assert!(
|
||||
matches!(error, CdpError::Protocol(_)),
|
||||
"expected Protocol, got {error:?}"
|
||||
);
|
||||
|
||||
let _log = server.await.expect("the fake relay must finish");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_navigation_without_a_load_event_returns_the_command_result() {
|
||||
// When nothing navigates there is no load event; the wait expires and the
|
||||
// command's own result is the answer, not a failure.
|
||||
let (ws_url, server) = fake_relay(RelayBehaviour::NeverLoadEvent).await;
|
||||
let target = CdpTarget::Local { ws_url };
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
navigate(&target, "https://example.com", 1)
|
||||
.await
|
||||
.expect("an answered command with no load event must still resolve");
|
||||
assert!(
|
||||
started.elapsed() >= Duration::from_secs(1),
|
||||
"the wait must run to its deadline when no load event arrives"
|
||||
);
|
||||
|
||||
let _log = server.await.expect("the fake relay must finish");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! SHA256 helpers shared by the app self-updater and the browser downloader.
|
||||
//!
|
||||
//! Both verify a downloaded artifact against a digest published beside it, so
|
||||
//! the hashing and the `sha256sum` parsing live here instead of in either
|
||||
//! caller.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Stream `path` through SHA256 and return the lowercase hex digest. Reads in
|
||||
/// 1 MiB blocks so a multi-gigabyte browser archive never lands in memory.
|
||||
pub fn sha256_file(path: &Path) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read;
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = vec![0u8; 1024 * 1024];
|
||||
loop {
|
||||
let n = file.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..n]);
|
||||
}
|
||||
let digest = hasher.finalize();
|
||||
let mut hex = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(hex, "{byte:02x}");
|
||||
}
|
||||
Ok(hex)
|
||||
}
|
||||
|
||||
/// Extract the hex digest for `filename` from standard `sha256sum` output
|
||||
/// (`<hex> <name>`, optionally with the `*` binary-mode marker).
|
||||
pub fn find_checksum_for_file(checksums_text: &str, filename: &str) -> Option<String> {
|
||||
checksums_text.lines().find_map(|line| {
|
||||
let (hash, rest) = line.split_once(char::is_whitespace)?;
|
||||
let name = rest.trim_start().trim_start_matches('*');
|
||||
if name == filename && is_sha256_hex(hash) {
|
||||
Some(hash.to_ascii_lowercase())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Digest from a single-asset `<file>.sha256` sidecar.
|
||||
///
|
||||
/// Prefers the entry named `filename`, because a name binds the digest to the
|
||||
/// asset it covers. Falls back to a lone digest only when the sidecar holds
|
||||
/// exactly one: `sha256sum < file` writes `-` as the name and some publishers
|
||||
/// emit the bare hash, and neither is ambiguous when it stands alone. A
|
||||
/// sidecar listing several assets always needs the name to match.
|
||||
pub fn parse_sidecar_digest(text: &str, filename: &str) -> Option<String> {
|
||||
if let Some(named) = find_checksum_for_file(text, filename) {
|
||||
return Some(named);
|
||||
}
|
||||
|
||||
let mut digests = text
|
||||
.lines()
|
||||
.filter_map(|line| line.split_whitespace().next())
|
||||
.filter(|token| is_sha256_hex(token));
|
||||
let only = digests.next()?;
|
||||
if digests.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(only.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn is_sha256_hex(value: &str) -> bool {
|
||||
value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const HELLO_WORLD_SHA256: &str =
|
||||
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
|
||||
|
||||
#[test]
|
||||
fn test_find_checksum_for_file() {
|
||||
let sums = "\
|
||||
0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11 Donut_0.29.0_aarch64.dmg
|
||||
ABCDEF01745092B7D1C93C1E7E1C30D923BE3D1E916B661BD53D1C0C9C7F0A22 *Donut_0.29.0_x64.dmg
|
||||
not-a-hash Donut_0.29.0_amd64.deb
|
||||
";
|
||||
|
||||
// Plain entry.
|
||||
assert_eq!(
|
||||
find_checksum_for_file(sums, "Donut_0.29.0_aarch64.dmg").as_deref(),
|
||||
Some("0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11")
|
||||
);
|
||||
// Binary-mode marker is stripped; hash is normalized to lowercase.
|
||||
assert_eq!(
|
||||
find_checksum_for_file(sums, "Donut_0.29.0_x64.dmg").as_deref(),
|
||||
Some("abcdef01745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a22")
|
||||
);
|
||||
// Entries with malformed hashes are rejected rather than trusted.
|
||||
assert_eq!(find_checksum_for_file(sums, "Donut_0.29.0_amd64.deb"), None);
|
||||
// Missing file.
|
||||
assert_eq!(find_checksum_for_file(sums, "Donut_0.29.0_arm64.deb"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sha256_file_matches_known_digest() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let path = temp_dir.path().join("data.bin");
|
||||
std::fs::write(&path, b"hello world").unwrap();
|
||||
assert_eq!(sha256_file(&path).unwrap(), HELLO_WORLD_SHA256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sidecar_digest_prefers_the_named_entry() {
|
||||
// The real Wayfern sidecar shape: `<hex> <name>`, one asset per file.
|
||||
let sidecar = format!("{HELLO_WORLD_SHA256} wayfern-151.0.7922.71_windows_x64.zip\n");
|
||||
assert_eq!(
|
||||
parse_sidecar_digest(&sidecar, "wayfern-151.0.7922.71_windows_x64.zip").as_deref(),
|
||||
Some(HELLO_WORLD_SHA256)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sidecar_digest_accepts_an_unnamed_lone_digest() {
|
||||
// `sha256sum < file` writes `-` as the name, and some publishers emit the
|
||||
// bare hash. Both cover the one asset the sidecar sits beside.
|
||||
for sidecar in [
|
||||
format!("{HELLO_WORLD_SHA256} -\n"),
|
||||
format!("{HELLO_WORLD_SHA256}\n"),
|
||||
format!(" {HELLO_WORLD_SHA256} \n"),
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_sidecar_digest(&sidecar, "wayfern.zip").as_deref(),
|
||||
Some(HELLO_WORLD_SHA256),
|
||||
"should accept lone digest in {sidecar:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sidecar_digest_normalizes_case() {
|
||||
let sidecar = format!("{} -\n", HELLO_WORLD_SHA256.to_ascii_uppercase());
|
||||
assert_eq!(
|
||||
parse_sidecar_digest(&sidecar, "wayfern.zip").as_deref(),
|
||||
Some(HELLO_WORLD_SHA256)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sidecar_digest_rejects_an_ambiguous_multi_entry_sidecar() {
|
||||
let sidecar = format!(
|
||||
"{HELLO_WORLD_SHA256} other.zip\n\
|
||||
ABCDEF01745092B7D1C93C1E7E1C30D923BE3D1E916B661BD53D1C0C9C7F0A22 another.zip\n"
|
||||
);
|
||||
// Two candidates and neither is named `wayfern.zip`: guessing would defeat
|
||||
// the point of the check.
|
||||
assert_eq!(parse_sidecar_digest(&sidecar, "wayfern.zip"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sidecar_digest_rejects_junk() {
|
||||
assert_eq!(parse_sidecar_digest("", "wayfern.zip"), None);
|
||||
assert_eq!(
|
||||
parse_sidecar_digest("not-a-hash wayfern.zip", "wayfern.zip"),
|
||||
None
|
||||
);
|
||||
// An HTML error page served with HTTP 200 must not read as a digest.
|
||||
assert_eq!(
|
||||
parse_sidecar_digest("<!doctype html><title>404</title>", "wayfern.zip"),
|
||||
None
|
||||
);
|
||||
// Right shape, wrong length.
|
||||
assert_eq!(
|
||||
parse_sidecar_digest("abc123 wayfern.zip", "wayfern.zip"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::browser::ProxySettings;
|
||||
@@ -1618,54 +1617,11 @@ pub async fn cloud_get_proxy_usage() -> Result<Option<CloudProxyUsage>, String>
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
// Stop existing scheduler
|
||||
if let Some(scheduler) = sync::get_global_scheduler() {
|
||||
scheduler.stop();
|
||||
}
|
||||
|
||||
// Restart sync pipeline
|
||||
let app_handle_sync = app_handle.clone();
|
||||
// Rebuilding the pipeline reaches the network, so do it off the command and
|
||||
// let the caller's dialog close. `start_pipeline` retires the previous
|
||||
// scheduler and the previous subscription itself.
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut subscription_manager = sync::SubscriptionManager::new();
|
||||
let work_rx = subscription_manager.take_work_receiver();
|
||||
|
||||
if let Err(e) = subscription_manager.start(app_handle_sync.clone()).await {
|
||||
log::warn!("Failed to start sync subscription: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(work_rx) = work_rx {
|
||||
let scheduler = Arc::new(sync::SyncScheduler::new());
|
||||
sync::set_global_scheduler(scheduler.clone());
|
||||
|
||||
scheduler.sync_all_enabled_profiles(&app_handle_sync).await;
|
||||
|
||||
match sync::SyncEngine::create_from_settings(&app_handle_sync).await {
|
||||
Ok(engine) => {
|
||||
if let Err(e) = engine
|
||||
.check_for_missing_synced_profiles(&app_handle_sync)
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to check for missing profiles: {}", e);
|
||||
}
|
||||
if let Err(e) = engine
|
||||
.check_for_missing_synced_entities(&app_handle_sync)
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to check for missing entities: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Sync not configured, skipping missing profile check: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
scheduler
|
||||
.clone()
|
||||
.start(app_handle_sync.clone(), work_rx)
|
||||
.await;
|
||||
log::info!("Sync scheduler restarted");
|
||||
}
|
||||
sync::start_pipeline(app_handle).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
||||
+692
-356
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+648
-188
@@ -1,7 +1,31 @@
|
||||
use serde::Serialize;
|
||||
use tauri::command;
|
||||
|
||||
pub struct DefaultBrowser {}
|
||||
|
||||
/// What happened when the user asked Donut to become the default browser.
|
||||
///
|
||||
/// macOS and Linux let a program make the change itself. Windows does not. The
|
||||
/// registry value that decides the handler carries a signature only the shell
|
||||
/// can produce, so the most a program may do is register itself and open the
|
||||
/// page where the user makes the choice. Without this distinction the caller
|
||||
/// reports a change that has not happened yet, which is what the Windows path
|
||||
/// used to do.
|
||||
///
|
||||
/// Each platform builds exactly one of these, so on any single target the other
|
||||
/// one reads as never constructed. That is what the allow is for: the variant is
|
||||
/// live, just not on the host being compiled.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "status")]
|
||||
#[allow(dead_code)]
|
||||
pub enum SetDefaultOutcome {
|
||||
/// Donut is the default browser now. Nothing is left for the user to do.
|
||||
Set,
|
||||
/// Registration is complete and the system settings page is open. The user
|
||||
/// makes the final choice there.
|
||||
AwaitingSystemSettings,
|
||||
}
|
||||
|
||||
impl DefaultBrowser {
|
||||
fn new() -> Self {
|
||||
Self {}
|
||||
@@ -21,7 +45,7 @@ impl DefaultBrowser {
|
||||
// Linux answers this by running `xdg-mime`, a shell script that forks
|
||||
// further. That is blocking work with no upper bound, and this command
|
||||
// runs on the same async runtime as every other command, the REST API and
|
||||
// the sync scheduler — so doing it inline occupies a worker for as long as
|
||||
// the sync scheduler, so doing it inline occupies a worker for as long as
|
||||
// the desktop takes to answer. The Settings page polls this on a timer.
|
||||
#[cfg(target_os = "linux")]
|
||||
return blocking(linux::is_default_browser).await;
|
||||
@@ -30,16 +54,22 @@ impl DefaultBrowser {
|
||||
Err("Unsupported platform".to_string())
|
||||
}
|
||||
|
||||
pub async fn set_as_default_browser(&self) -> Result<(), String> {
|
||||
pub async fn set_as_default_browser(&self) -> Result<SetDefaultOutcome, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::set_as_default_browser();
|
||||
return macos::set_as_default_browser().map(|()| SetDefaultOutcome::Set);
|
||||
|
||||
// Windows writes several registry trees, broadcasts `WM_SETTINGCHANGE` to
|
||||
// every top-level window on the desktop and then hands off to the shell.
|
||||
// The broadcast alone costs about 130 ms on an idle desktop and seconds on
|
||||
// a busy one, so this does not belong on a runtime worker either.
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::set_as_default_browser();
|
||||
return blocking(windows::set_as_default_browser).await;
|
||||
|
||||
// Same reasoning, and this one additionally sleeps 500ms before verifying.
|
||||
#[cfg(target_os = "linux")]
|
||||
return blocking(linux::set_as_default_browser).await;
|
||||
return blocking(linux::set_as_default_browser)
|
||||
.await
|
||||
.map(|()| SetDefaultOutcome::Set);
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
Err("Unsupported platform".to_string())
|
||||
@@ -47,7 +77,7 @@ impl DefaultBrowser {
|
||||
}
|
||||
|
||||
/// Run blocking work off the async runtime's worker threads.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
async fn blocking<T, F>(work: F) -> Result<T, String>
|
||||
where
|
||||
F: FnOnce() -> Result<T, String> + Send + 'static,
|
||||
@@ -124,18 +154,44 @@ mod macos {
|
||||
#[cfg(target_os = "windows")]
|
||||
#[allow(clippy::needless_borrows_for_generic_args)]
|
||||
mod windows {
|
||||
use super::SetDefaultOutcome;
|
||||
use std::path::Path;
|
||||
use winreg::enums::*;
|
||||
use winreg::RegKey;
|
||||
|
||||
/// The key Windows knows us by. Never shown to a person.
|
||||
const APP_NAME: &str = "DonutBrowser";
|
||||
/// The name Windows shows in "Default apps" and in "Open with".
|
||||
const DISPLAY_NAME: &str = "Donut Browser";
|
||||
const DESCRIPTION: &str = "Donut Browser - Simple Yet Powerful Anti-Detect Browser";
|
||||
const PROG_ID: &str = "DonutBrowser.HTML";
|
||||
|
||||
pub fn is_default_browser() -> Result<bool, String> {
|
||||
let schemes = ["http", "https"];
|
||||
/// A web browser registers under `StartMenuInternet`, and
|
||||
/// `RegisteredApplications` points at the `Capabilities` subkey of that
|
||||
/// entry. Edge, Chrome and Firefox all do exactly this, and the shell reads
|
||||
/// the capability data from there.
|
||||
///
|
||||
/// The previous layout invented its own key at `Software\DonutBrowser` and
|
||||
/// pointed `RegisteredApplications` at the parent instead of at
|
||||
/// `Capabilities`. Every other entry on a normal machine ends in
|
||||
/// `Capabilities`. The shell found no capability data, so Donut was never
|
||||
/// offered as a browser and the button appeared to do nothing.
|
||||
const CLIENT_KEY: &str = r"Software\Clients\StartMenuInternet\DonutBrowser";
|
||||
/// The value written into `RegisteredApplications`.
|
||||
const CAPABILITIES_KEY: &str = r"Software\Clients\StartMenuInternet\DonutBrowser\Capabilities";
|
||||
/// The layout earlier builds wrote. Removed on every run, so a machine that
|
||||
/// ran one of those does not keep stale capability data claiming http.
|
||||
const LEGACY_APP_KEY: &str = r"Software\DonutBrowser";
|
||||
|
||||
for scheme in schemes {
|
||||
// Check if our browser is set as the default handler for this scheme
|
||||
const URL_SCHEMES: [&str; 2] = ["http", "https"];
|
||||
/// The file types a browser is asked to open from Explorer. The ProgId
|
||||
/// command passes the path through as `%1`, and `urls_from_args` in `lib.rs`
|
||||
/// turns a path into a `file://` URL, so every extension listed here can
|
||||
/// actually be serviced. Do not add one that cannot.
|
||||
const FILE_EXTENSIONS: [&str; 4] = [".htm", ".html", ".shtml", ".xhtml"];
|
||||
|
||||
pub fn is_default_browser() -> Result<bool, String> {
|
||||
for scheme in URL_SCHEMES {
|
||||
if !is_default_for_scheme(scheme)? {
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -144,44 +200,42 @@ mod windows {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn set_as_default_browser() -> Result<(), String> {
|
||||
// Get the current executable path
|
||||
let exe_path = std::env::current_exe()
|
||||
.map_err(|e| format!("Failed to get current executable path: {}", e))?;
|
||||
pub fn set_as_default_browser() -> Result<SetDefaultOutcome, String> {
|
||||
let exe_path =
|
||||
std::env::current_exe().map_err(|e| format!("Failed to get current executable path: {e}"))?;
|
||||
|
||||
let exe_path_str = exe_path
|
||||
let exe_path = exe_path
|
||||
.to_str()
|
||||
.ok_or("Failed to convert executable path to string")?;
|
||||
|
||||
// Verify the executable exists
|
||||
if !Path::new(exe_path_str).exists() {
|
||||
return Err(format!("Executable not found at: {}", exe_path_str));
|
||||
if !Path::new(exe_path).exists() {
|
||||
return Err(format!("Executable not found at: {exe_path}"));
|
||||
}
|
||||
|
||||
// Register the application
|
||||
register_application(exe_path_str)?;
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
remove_legacy_registration(&hkcu);
|
||||
register_prog_id(&hkcu, exe_path)?;
|
||||
register_client(&hkcu, exe_path)?;
|
||||
register_file_extensions(&hkcu)?;
|
||||
register_application(&hkcu)?;
|
||||
|
||||
// Set as default for HTTP and HTTPS
|
||||
set_default_for_scheme("http")?;
|
||||
set_default_for_scheme("https")?;
|
||||
|
||||
// Register file associations for HTML files
|
||||
register_html_file_association(exe_path_str)?;
|
||||
|
||||
// Notify the system of changes
|
||||
notify_system_of_changes();
|
||||
|
||||
Ok(())
|
||||
open_default_apps_settings()?;
|
||||
|
||||
Ok(SetDefaultOutcome::AwaitingSystemSettings)
|
||||
}
|
||||
|
||||
/// Wrap a path in the quotes the shell expects around a command or an icon.
|
||||
fn quoted(value: &str) -> String {
|
||||
format!(r#""{value}""#)
|
||||
}
|
||||
|
||||
fn is_default_for_scheme(scheme: &str) -> Result<bool, String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
|
||||
// Check Software\Microsoft\Windows\Shell\Associations\UrlAssociations\{scheme}\UserChoice
|
||||
let path = format!(
|
||||
"Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\{}\\UserChoice",
|
||||
scheme
|
||||
);
|
||||
let path =
|
||||
format!(r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\{scheme}\UserChoice");
|
||||
|
||||
match hkcu.open_subkey(&path) {
|
||||
Ok(key) => match key.get_value::<String, _>("ProgId") {
|
||||
@@ -192,204 +246,512 @@ mod windows {
|
||||
}
|
||||
}
|
||||
|
||||
fn register_application(exe_path: &str) -> Result<(), String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
/// Delete the layout earlier builds wrote.
|
||||
///
|
||||
/// Nothing else in this application has ever written under that key, so
|
||||
/// removing it cannot lose anything a user cares about. Leaving it would
|
||||
/// leave a second `Capabilities` block claiming http and https from a key the
|
||||
/// shell no longer reads.
|
||||
///
|
||||
/// The old code also wrote the ProgId into the default value of
|
||||
/// `Software\Classes\.html` and `.htm`. That value is the association itself,
|
||||
/// and it was never ours to take. Give it back, but only where it still holds
|
||||
/// the ProgId we wrote. Any other value is the user's own choice and is left
|
||||
/// alone.
|
||||
fn remove_legacy_registration(root: &RegKey) {
|
||||
match root.delete_subkey_all(LEGACY_APP_KEY) {
|
||||
Ok(()) => log::debug!("Removed the superseded default-browser registration key"),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => log::debug!("Could not remove the superseded registration key: {e}"),
|
||||
}
|
||||
|
||||
// Register in Software\RegisteredApplications
|
||||
let (registered_apps, _) = hkcu
|
||||
.create_subkey("Software\\RegisteredApplications")
|
||||
.map_err(|e| format!("Failed to create RegisteredApplications key: {}", e))?;
|
||||
for extension in [".htm", ".html"] {
|
||||
let path = format!(r"Software\Classes\{extension}");
|
||||
let Ok(key) = root.open_subkey_with_flags(&path, KEY_READ | KEY_SET_VALUE) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
registered_apps
|
||||
.set_value(APP_NAME, &format!("Software\\{}", APP_NAME))
|
||||
.map_err(|e| format!("Failed to set registered application: {}", e))?;
|
||||
let ours = key
|
||||
.get_value::<String, _>("")
|
||||
.map(|value| value == PROG_ID)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Create application key
|
||||
let (app_key, _) = hkcu
|
||||
.create_subkey(&format!("Software\\{}", APP_NAME))
|
||||
.map_err(|e| format!("Failed to create application key: {}", e))?;
|
||||
|
||||
// Set application properties
|
||||
app_key
|
||||
.set_value("ApplicationName", &APP_NAME)
|
||||
.map_err(|e| format!("Failed to set ApplicationName: {}", e))?;
|
||||
|
||||
app_key
|
||||
.set_value(
|
||||
"ApplicationDescription",
|
||||
&"Donut Browser - Simple Yet Powerful Anti-Detect Browser",
|
||||
)
|
||||
.map_err(|e| format!("Failed to set ApplicationDescription: {}", e))?;
|
||||
|
||||
app_key
|
||||
.set_value("ApplicationIcon", &format!("\"{}\",0", exe_path))
|
||||
.map_err(|e| format!("Failed to set ApplicationIcon: {}", e))?;
|
||||
|
||||
// Create Capabilities key
|
||||
let (capabilities, _) = app_key
|
||||
.create_subkey("Capabilities")
|
||||
.map_err(|e| format!("Failed to create Capabilities key: {}", e))?;
|
||||
|
||||
capabilities
|
||||
.set_value(
|
||||
"ApplicationDescription",
|
||||
&"Donut Browser - Simple Yet Powerful Anti-Detect Browser",
|
||||
)
|
||||
.map_err(|e| format!("Failed to set Capabilities description: {}", e))?;
|
||||
|
||||
// Set URL associations
|
||||
let (url_assoc, _) = capabilities
|
||||
.create_subkey("URLAssociations")
|
||||
.map_err(|e| format!("Failed to create URLAssociations key: {}", e))?;
|
||||
|
||||
url_assoc
|
||||
.set_value("http", &PROG_ID)
|
||||
.map_err(|e| format!("Failed to set http association: {}", e))?;
|
||||
|
||||
url_assoc
|
||||
.set_value("https", &PROG_ID)
|
||||
.map_err(|e| format!("Failed to set https association: {}", e))?;
|
||||
|
||||
// Set file associations
|
||||
let (file_assoc, _) = capabilities
|
||||
.create_subkey("FileAssociations")
|
||||
.map_err(|e| format!("Failed to create FileAssociations key: {}", e))?;
|
||||
|
||||
file_assoc
|
||||
.set_value(".html", &PROG_ID)
|
||||
.map_err(|e| format!("Failed to set .html association: {}", e))?;
|
||||
|
||||
file_assoc
|
||||
.set_value(".htm", &PROG_ID)
|
||||
.map_err(|e| format!("Failed to set .htm association: {}", e))?;
|
||||
|
||||
// Register the ProgID
|
||||
register_prog_id(exe_path)?;
|
||||
|
||||
Ok(())
|
||||
if ours {
|
||||
match key.delete_value("") {
|
||||
Ok(()) => log::debug!("Released the {extension} association taken by an older build"),
|
||||
Err(e) => log::debug!("Could not release the {extension} association: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn register_prog_id(exe_path: &str) -> Result<(), String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
|
||||
// Create ProgID key
|
||||
let (prog_id_key, _) = hkcu
|
||||
.create_subkey(&format!("Software\\Classes\\{}", PROG_ID))
|
||||
.map_err(|e| format!("Failed to create ProgID key: {}", e))?;
|
||||
/// Describe the document type Donut opens, and how to open one.
|
||||
fn register_prog_id(root: &RegKey, exe_path: &str) -> Result<(), String> {
|
||||
let (prog_id_key, _) = root
|
||||
.create_subkey(format!(r"Software\Classes\{PROG_ID}"))
|
||||
.map_err(|e| format!("Failed to create ProgID key: {e}"))?;
|
||||
|
||||
prog_id_key
|
||||
.set_value("", &"Donut Browser Document")
|
||||
.map_err(|e| format!("Failed to set ProgID default value: {}", e))?;
|
||||
.map_err(|e| format!("Failed to set ProgID default value: {e}"))?;
|
||||
|
||||
prog_id_key
|
||||
.set_value("FriendlyTypeName", &"Donut Browser Document")
|
||||
.map_err(|e| format!("Failed to set FriendlyTypeName: {}", e))?;
|
||||
.map_err(|e| format!("Failed to set FriendlyTypeName: {e}"))?;
|
||||
|
||||
// The shell reads this block to put a name and an icon beside the ProgId in
|
||||
// the "Open with" list. Without it the entry shows as the raw ProgId.
|
||||
let (application, _) = prog_id_key
|
||||
.create_subkey("Application")
|
||||
.map_err(|e| format!("Failed to create ProgID Application key: {e}"))?;
|
||||
|
||||
application
|
||||
.set_value("ApplicationName", &DISPLAY_NAME)
|
||||
.map_err(|e| format!("Failed to set ProgID ApplicationName: {e}"))?;
|
||||
|
||||
application
|
||||
.set_value("ApplicationIcon", &format!("{},0", quoted(exe_path)))
|
||||
.map_err(|e| format!("Failed to set ProgID ApplicationIcon: {e}"))?;
|
||||
|
||||
// Create DefaultIcon key
|
||||
let (icon_key, _) = prog_id_key
|
||||
.create_subkey("DefaultIcon")
|
||||
.map_err(|e| format!("Failed to create DefaultIcon key: {}", e))?;
|
||||
.map_err(|e| format!("Failed to create DefaultIcon key: {e}"))?;
|
||||
|
||||
icon_key
|
||||
.set_value("", &format!("\"{}\",0", exe_path))
|
||||
.map_err(|e| format!("Failed to set default icon: {}", e))?;
|
||||
.set_value("", &format!("{},0", quoted(exe_path)))
|
||||
.map_err(|e| format!("Failed to set default icon: {e}"))?;
|
||||
|
||||
// Create shell\open\command key
|
||||
let (command_key, _) = prog_id_key
|
||||
.create_subkey("shell\\open\\command")
|
||||
.map_err(|e| format!("Failed to create command key: {}", e))?;
|
||||
.create_subkey(r"shell\open\command")
|
||||
.map_err(|e| format!("Failed to create command key: {e}"))?;
|
||||
|
||||
command_key
|
||||
.set_value("", &format!("\"{}\" \"%1\"", exe_path))
|
||||
.map_err(|e| format!("Failed to set command: {}", e))?;
|
||||
.set_value("", &format!(r#"{} "%1""#, quoted(exe_path)))
|
||||
.map_err(|e| format!("Failed to set command: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_default_for_scheme(scheme: &str) -> Result<(), String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
/// The `StartMenuInternet` entry: the shape the shell reads for a web
|
||||
/// browser. A display name, an icon, the command that starts it, the
|
||||
/// `InstallInfo` block the default-programs page expects, and the capability
|
||||
/// lists that say which schemes and file types it handles.
|
||||
fn register_client(root: &RegKey, exe_path: &str) -> Result<(), String> {
|
||||
let (client, _) = root
|
||||
.create_subkey(CLIENT_KEY)
|
||||
.map_err(|e| format!("Failed to create browser client key: {e}"))?;
|
||||
|
||||
// Set in Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.html\UserChoice
|
||||
// Note: On Windows 10+, this might require elevated permissions or user interaction
|
||||
// through the Settings app due to security restrictions
|
||||
client
|
||||
.set_value("", &DISPLAY_NAME)
|
||||
.map_err(|e| format!("Failed to set client display name: {e}"))?;
|
||||
|
||||
// Try to set the association in the user's choice
|
||||
let user_choice_path = format!(
|
||||
"Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\{}\\UserChoice",
|
||||
scheme
|
||||
);
|
||||
let (icon, _) = client
|
||||
.create_subkey("DefaultIcon")
|
||||
.map_err(|e| format!("Failed to create client DefaultIcon key: {e}"))?;
|
||||
|
||||
// Note: Setting UserChoice directly may not work on Windows 10+ due to hash verification
|
||||
// The user may need to manually set the default browser through Windows Settings
|
||||
match hkcu.create_subkey(&user_choice_path) {
|
||||
Ok((user_choice, _)) => {
|
||||
// Attempt to set the ProgId
|
||||
if user_choice.set_value("ProgId", &PROG_ID).is_err() {
|
||||
// If we can't set UserChoice, that's expected on newer Windows versions
|
||||
// The registration is still valuable for the "Open with" menu
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Expected on newer Windows versions - user must set manually
|
||||
}
|
||||
icon
|
||||
.set_value("", &format!("{},0", quoted(exe_path)))
|
||||
.map_err(|e| format!("Failed to set client icon: {e}"))?;
|
||||
|
||||
let (command, _) = client
|
||||
.create_subkey(r"shell\open\command")
|
||||
.map_err(|e| format!("Failed to create client command key: {e}"))?;
|
||||
|
||||
// No `%1` here. This entry is how the shell starts the browser with no
|
||||
// document, for example from the Start menu.
|
||||
command
|
||||
.set_value("", "ed(exe_path))
|
||||
.map_err(|e| format!("Failed to set client command: {e}"))?;
|
||||
|
||||
// The shell reads the icons-visible state from here, so the block has to
|
||||
// exist. It also understands `ReinstallCommand`, `HideIconsCommand` and
|
||||
// `ShowIconsCommand`, and Edge and Chrome advertise all three. Donut does
|
||||
// not, because it does not act on `--make-default-browser`, `--hide-icons`
|
||||
// or `--show-icons`. Advertising a command the program ignores is the same
|
||||
// empty claim as registering a file type nothing can open. Add them here on
|
||||
// the day the flags do something.
|
||||
let (install_info, _) = client
|
||||
.create_subkey("InstallInfo")
|
||||
.map_err(|e| format!("Failed to create InstallInfo key: {e}"))?;
|
||||
|
||||
install_info
|
||||
.set_value("IconsVisible", &1u32)
|
||||
.map_err(|e| format!("Failed to set IconsVisible: {e}"))?;
|
||||
|
||||
let (capabilities, _) = client
|
||||
.create_subkey("Capabilities")
|
||||
.map_err(|e| format!("Failed to create Capabilities key: {e}"))?;
|
||||
|
||||
// `ApplicationName` belongs inside `Capabilities`. The old code wrote it one
|
||||
// level up, where the shell does not look, so the entry had no name.
|
||||
capabilities
|
||||
.set_value("ApplicationName", &DISPLAY_NAME)
|
||||
.map_err(|e| format!("Failed to set ApplicationName: {e}"))?;
|
||||
|
||||
capabilities
|
||||
.set_value("ApplicationDescription", &DESCRIPTION)
|
||||
.map_err(|e| format!("Failed to set ApplicationDescription: {e}"))?;
|
||||
|
||||
capabilities
|
||||
.set_value("ApplicationIcon", &format!("{},0", quoted(exe_path)))
|
||||
.map_err(|e| format!("Failed to set ApplicationIcon: {e}"))?;
|
||||
|
||||
let (url_assoc, _) = capabilities
|
||||
.create_subkey("URLAssociations")
|
||||
.map_err(|e| format!("Failed to create URLAssociations key: {e}"))?;
|
||||
|
||||
for scheme in URL_SCHEMES {
|
||||
url_assoc
|
||||
.set_value(scheme, &PROG_ID)
|
||||
.map_err(|e| format!("Failed to set {scheme} association: {e}"))?;
|
||||
}
|
||||
|
||||
let (file_assoc, _) = capabilities
|
||||
.create_subkey("FileAssociations")
|
||||
.map_err(|e| format!("Failed to create FileAssociations key: {e}"))?;
|
||||
|
||||
for extension in FILE_EXTENSIONS {
|
||||
file_assoc
|
||||
.set_value(extension, &PROG_ID)
|
||||
.map_err(|e| format!("Failed to set {extension} association: {e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register_html_file_association(_exe_path: &str) -> Result<(), String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
/// Offer Donut in the "Open with" list for the HTML file types, without
|
||||
/// taking the association away from whatever the user already chose.
|
||||
///
|
||||
/// The old code wrote the ProgId into the default value of
|
||||
/// `Software\Classes\.html`, which is the association itself. That replaced
|
||||
/// the user's choice without asking, was never undone on uninstall, and did
|
||||
/// not even take effect, because the per-user `FileExts` choice outranks it.
|
||||
/// `OpenWithProgids` is the additive form: it adds Donut to the list and
|
||||
/// displaces nothing.
|
||||
fn register_file_extensions(root: &RegKey) -> Result<(), String> {
|
||||
for extension in FILE_EXTENSIONS {
|
||||
let (open_with, _) = root
|
||||
.create_subkey(format!(r"Software\Classes\{extension}\OpenWithProgids"))
|
||||
.map_err(|e| format!("Failed to create OpenWithProgids key for {extension}: {e}"))?;
|
||||
|
||||
// Register .html and .htm file associations
|
||||
for ext in &[".html", ".htm"] {
|
||||
let ext_path = format!("Software\\Classes\\{}", ext);
|
||||
|
||||
match hkcu.create_subkey(&ext_path) {
|
||||
Ok((ext_key, _)) => {
|
||||
// Set the default value to our ProgID
|
||||
let _ = ext_key.set_value("", &PROG_ID);
|
||||
}
|
||||
Err(_) => {
|
||||
// Continue if we can't set the file association
|
||||
}
|
||||
}
|
||||
// Only the value name matters here. The payload is a marker.
|
||||
open_with
|
||||
.set_value(PROG_ID, &"")
|
||||
.map_err(|e| format!("Failed to register the {extension} handler: {e}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Point `RegisteredApplications` at the capability data. This is what puts
|
||||
/// Donut in the list Windows offers under "Default apps".
|
||||
fn register_application(root: &RegKey) -> Result<(), String> {
|
||||
let (registered_apps, _) = root
|
||||
.create_subkey(r"Software\RegisteredApplications")
|
||||
.map_err(|e| format!("Failed to create RegisteredApplications key: {e}"))?;
|
||||
|
||||
registered_apps
|
||||
.set_value(APP_NAME, &CAPABILITIES_KEY)
|
||||
.map_err(|e| format!("Failed to set registered application: {e}"))
|
||||
}
|
||||
|
||||
/// Open the page where the user chooses the default browser.
|
||||
///
|
||||
/// Windows does not let a program make itself the default. The value that
|
||||
/// decides the handler, the `UserChoice` key under `UrlAssociations`, carries
|
||||
/// a hash over the user's SID, the ProgId and a timestamp, and only the shell
|
||||
/// can produce it. Windows 11 also ships UCPD.sys, which blocks writes to
|
||||
/// those keys outright.
|
||||
///
|
||||
/// The old code wrote `ProgId` there with no hash and discarded every error,
|
||||
/// then reported success. The registry never changed, the Settings page went
|
||||
/// on saying "Inactive", and the user was told nothing. Registration is the
|
||||
/// part a program is allowed to do. The choice belongs to the user, so open
|
||||
/// the page where they can make it and let the caller say so.
|
||||
fn open_default_apps_settings() -> Result<(), String> {
|
||||
use windows::core::{HSTRING, PCWSTR};
|
||||
use windows::Win32::System::Com::{
|
||||
CoInitializeEx, CoUninitialize, COINIT_APARTMENTTHREADED, COINIT_DISABLE_OLE1DDE,
|
||||
};
|
||||
use windows::Win32::UI::Shell::ShellExecuteW;
|
||||
use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
|
||||
|
||||
// `registeredAppUser` makes the page open on our entry rather than at the
|
||||
// top of the list. It is the name just written into
|
||||
// `RegisteredApplications`, so it only resolves because registration ran
|
||||
// first.
|
||||
let target = HSTRING::from(format!(
|
||||
"ms-settings:defaultapps?registeredAppUser={APP_NAME}"
|
||||
));
|
||||
let operation = HSTRING::from("open");
|
||||
|
||||
// ShellExecuteW hands the URI to a shell extension, and shell extensions
|
||||
// are COM objects. This runs on a `spawn_blocking` thread, which has no
|
||||
// apartment of its own, so give it one. An error means the thread already
|
||||
// had an apartment in another mode, and in that case it is not ours to
|
||||
// tear down.
|
||||
let com_status =
|
||||
unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE) };
|
||||
let owns_com = com_status.is_ok();
|
||||
|
||||
let result = unsafe {
|
||||
ShellExecuteW(
|
||||
None,
|
||||
PCWSTR(operation.as_ptr()),
|
||||
PCWSTR(target.as_ptr()),
|
||||
PCWSTR::null(),
|
||||
PCWSTR::null(),
|
||||
SW_SHOWNORMAL,
|
||||
)
|
||||
};
|
||||
|
||||
if owns_com {
|
||||
unsafe { CoUninitialize() };
|
||||
}
|
||||
|
||||
// ShellExecuteW reports success as a value above 32. Anything at or below
|
||||
// that is an error code wearing a handle's type.
|
||||
let code = result.0 as isize;
|
||||
if code <= 32 {
|
||||
return Err(format!(
|
||||
"Donut Browser is registered, but Windows Settings did not open (code {code}). Open Settings, then Apps, then Default apps, find Donut Browser and set it for HTTP and HTTPS."
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tell the shell that the association it has cached is stale.
|
||||
///
|
||||
/// `SHChangeNotify` is the documented announcement for an association change,
|
||||
/// and the `WM_SETTINGCHANGE` broadcast is what the shell's own settings UI
|
||||
/// sends alongside it, so both go out.
|
||||
///
|
||||
/// This used to hand-declare `SendMessageTimeoutA` with `lpdwResult` typed as
|
||||
/// `*mut u32` and pass it a `u32`. The real parameter is `PDWORD_PTR`, eight
|
||||
/// bytes on x64, so every call wrote four bytes past a stack slot. The result
|
||||
/// was a corrupted stack at the exact moment a user set Donut as their default
|
||||
/// browser, and the process died with nothing in the log. Go through the
|
||||
/// `windows` crate instead, which types the out-parameter correctly and cannot
|
||||
/// drift from the real ABI.
|
||||
fn notify_system_of_changes() {
|
||||
// Use Windows API to notify the system of association changes
|
||||
// This helps refresh the system's understanding of the changes
|
||||
use windows::core::w;
|
||||
use windows::Win32::Foundation::{LPARAM, WPARAM};
|
||||
use windows::Win32::UI::Shell::{SHChangeNotify, SHCNE_ASSOCCHANGED, SHCNF_IDLIST};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
SendMessageTimeoutW, HWND_BROADCAST, SMTO_ABORTIFHUNG, WM_SETTINGCHANGE,
|
||||
};
|
||||
|
||||
unsafe {
|
||||
use std::ffi::c_void;
|
||||
SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, None, None);
|
||||
|
||||
const HWND_BROADCAST: *mut c_void = 0xffff as *mut c_void;
|
||||
const WM_SETTINGCHANGE: u32 = 0x001A;
|
||||
const SMTO_ABORTIFHUNG: u32 = 0x0002;
|
||||
|
||||
extern "system" {
|
||||
fn SendMessageTimeoutA(
|
||||
hWnd: *mut c_void,
|
||||
Msg: u32,
|
||||
wParam: usize,
|
||||
lParam: isize,
|
||||
fuFlags: u32,
|
||||
uTimeout: u32,
|
||||
lpdwResult: *mut u32,
|
||||
) -> isize;
|
||||
}
|
||||
|
||||
let mut result: u32 = 0;
|
||||
|
||||
SendMessageTimeoutA(
|
||||
// The broadcast is best-effort: a hung top-level window elsewhere on the
|
||||
// desktop must not hold up the click that triggered this, hence the
|
||||
// timeout and SMTO_ABORTIFHUNG. `WM_SETTINGCHANGE`'s lParam string is
|
||||
// marshalled cross-process by the window manager, and this one is
|
||||
// 'static, so it stays valid for the whole call.
|
||||
let mut result: usize = 0;
|
||||
SendMessageTimeoutW(
|
||||
HWND_BROADCAST,
|
||||
WM_SETTINGCHANGE,
|
||||
0,
|
||||
c"Software\\Classes".as_ptr() as isize,
|
||||
WPARAM(0),
|
||||
LPARAM(w!("Software\\Classes").as_ptr() as isize),
|
||||
SMTO_ABORTIFHUNG,
|
||||
1000,
|
||||
&mut result,
|
||||
Some(&mut result),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod registration_tests {
|
||||
use super::*;
|
||||
|
||||
/// A scratch key that stands in for HKCU, so the test writes a real tree
|
||||
/// through the real code without touching the tree Windows actually reads.
|
||||
/// Deleted on the way out, including when an assertion fails.
|
||||
struct ScratchRoot {
|
||||
key: RegKey,
|
||||
path: String,
|
||||
}
|
||||
|
||||
const SCRATCH_PARENT: &str = r"Software\DonutBrowserTests";
|
||||
|
||||
impl ScratchRoot {
|
||||
fn new(name: &str) -> Self {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
let path = format!(r"{SCRATCH_PARENT}\{name}");
|
||||
let _ = hkcu.delete_subkey_all(&path);
|
||||
let (key, _) = hkcu.create_subkey(&path).expect("create the scratch root");
|
||||
Self { key, path }
|
||||
}
|
||||
|
||||
fn value(&self, subkey: &str, name: &str) -> Option<String> {
|
||||
self
|
||||
.key
|
||||
.open_subkey(subkey)
|
||||
.ok()?
|
||||
.get_value::<String, _>(name)
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScratchRoot {
|
||||
fn drop(&mut self) {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
let _ = hkcu.delete_subkey_all(&self.path);
|
||||
// Take the shared parent too, so a test run leaves nothing at all in
|
||||
// the user's registry. `delete_subkey` refuses a key that still has
|
||||
// children, which is exactly the guard needed while tests run in
|
||||
// parallel: whoever finishes last removes it.
|
||||
let _ = hkcu.delete_subkey(SCRATCH_PARENT);
|
||||
}
|
||||
}
|
||||
|
||||
const EXE: &str = r"C:\Program Files\Donut Browser\donutbrowser.exe";
|
||||
|
||||
#[test]
|
||||
fn registration_writes_the_shape_the_shell_reads() {
|
||||
let root = ScratchRoot::new("registration");
|
||||
register_prog_id(&root.key, EXE).expect("register the ProgId");
|
||||
register_client(&root.key, EXE).expect("register the client");
|
||||
register_file_extensions(&root.key).expect("register the file types");
|
||||
register_application(&root.key).expect("register the application");
|
||||
|
||||
// The bug that made the button do nothing: this pointed at the
|
||||
// application key instead of at its `Capabilities` subkey, so the shell
|
||||
// read no capabilities and never offered Donut as a browser. Every other
|
||||
// entry on a working machine ends in `Capabilities`.
|
||||
let registered = root
|
||||
.value(r"Software\RegisteredApplications", APP_NAME)
|
||||
.expect("RegisteredApplications entry");
|
||||
assert_eq!(registered, CAPABILITIES_KEY);
|
||||
assert!(
|
||||
registered.ends_with(r"\Capabilities"),
|
||||
"RegisteredApplications must name the Capabilities subkey, got {registered}"
|
||||
);
|
||||
assert!(
|
||||
root.key.open_subkey(®istered).is_ok(),
|
||||
"RegisteredApplications names {registered}, which does not exist"
|
||||
);
|
||||
|
||||
// The second bug: `ApplicationName` sat one level above `Capabilities`,
|
||||
// where the shell does not look, so the entry had no name to show.
|
||||
assert_eq!(
|
||||
root.value(CAPABILITIES_KEY, "ApplicationName").as_deref(),
|
||||
Some(DISPLAY_NAME)
|
||||
);
|
||||
assert_eq!(
|
||||
root
|
||||
.value(CAPABILITIES_KEY, "ApplicationDescription")
|
||||
.as_deref(),
|
||||
Some(DESCRIPTION)
|
||||
);
|
||||
|
||||
// Every scheme and file type the capability lists claim.
|
||||
for scheme in URL_SCHEMES {
|
||||
assert_eq!(
|
||||
root
|
||||
.value(&format!(r"{CAPABILITIES_KEY}\URLAssociations"), scheme)
|
||||
.as_deref(),
|
||||
Some(PROG_ID),
|
||||
"{scheme} is not claimed"
|
||||
);
|
||||
}
|
||||
for extension in FILE_EXTENSIONS {
|
||||
assert_eq!(
|
||||
root
|
||||
.value(&format!(r"{CAPABILITIES_KEY}\FileAssociations"), extension)
|
||||
.as_deref(),
|
||||
Some(PROG_ID),
|
||||
"{extension} is not claimed"
|
||||
);
|
||||
}
|
||||
|
||||
// The rest of the StartMenuInternet entry.
|
||||
assert_eq!(root.value(CLIENT_KEY, "").as_deref(), Some(DISPLAY_NAME));
|
||||
assert_eq!(
|
||||
root.value(&format!(r"{CLIENT_KEY}\shell\open\command"), ""),
|
||||
Some(quoted(EXE))
|
||||
);
|
||||
assert!(root
|
||||
.key
|
||||
.open_subkey(format!(r"{CLIENT_KEY}\InstallInfo"))
|
||||
.is_ok());
|
||||
|
||||
// The ProgId command has to carry `%1`. Without it the shell starts the
|
||||
// browser and never says which page to open.
|
||||
let prog_id_command = root
|
||||
.value(
|
||||
&format!(r"Software\Classes\{PROG_ID}\shell\open\command"),
|
||||
"",
|
||||
)
|
||||
.expect("ProgId command");
|
||||
assert_eq!(prog_id_command, format!(r#"{} "%1""#, quoted(EXE)));
|
||||
|
||||
// The file types are offered, not seized. Taking the default value of
|
||||
// `Software\Classes\.html` is what the old code did, and that value
|
||||
// belongs to whatever the user chose.
|
||||
for extension in FILE_EXTENSIONS {
|
||||
assert_eq!(
|
||||
root
|
||||
.value(
|
||||
&format!(r"Software\Classes\{extension}\OpenWithProgids"),
|
||||
PROG_ID
|
||||
)
|
||||
.as_deref(),
|
||||
Some(""),
|
||||
"{extension} should offer the handler"
|
||||
);
|
||||
assert!(
|
||||
root
|
||||
.value(&format!(r"Software\Classes\{extension}"), "")
|
||||
.is_none(),
|
||||
"{extension} default value must be left alone"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_association_an_older_build_took_is_given_back() {
|
||||
let root = ScratchRoot::new("legacy");
|
||||
|
||||
// Recreate what the old code left behind: its own application key, and
|
||||
// the ProgId written straight into the association for one file type.
|
||||
let (legacy, _) = root
|
||||
.key
|
||||
.create_subkey(format!(r"{LEGACY_APP_KEY}\Capabilities\URLAssociations"))
|
||||
.expect("legacy key");
|
||||
legacy.set_value("http", &PROG_ID).expect("legacy claim");
|
||||
|
||||
let (html, _) = root
|
||||
.key
|
||||
.create_subkey(r"Software\Classes\.html")
|
||||
.expect("html class");
|
||||
html.set_value("", &PROG_ID).expect("legacy association");
|
||||
|
||||
// A file type the user pointed somewhere else. This one is not ours and
|
||||
// must survive untouched.
|
||||
let (htm, _) = root
|
||||
.key
|
||||
.create_subkey(r"Software\Classes\.htm")
|
||||
.expect("htm class");
|
||||
htm.set_value("", &"ChromeHTML").expect("user association");
|
||||
|
||||
remove_legacy_registration(&root.key);
|
||||
|
||||
assert!(
|
||||
root.key.open_subkey(LEGACY_APP_KEY).is_err(),
|
||||
"the superseded application key should be gone"
|
||||
);
|
||||
assert!(
|
||||
root.value(r"Software\Classes\.html", "").is_none(),
|
||||
"the association we took should have been released"
|
||||
);
|
||||
assert_eq!(
|
||||
root.value(r"Software\Classes\.htm", "").as_deref(),
|
||||
Some("ChromeHTML"),
|
||||
"a choice that is not ours must not be touched"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -545,7 +907,105 @@ pub async fn is_default_browser() -> Result<bool, String> {
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn set_as_default_browser() -> Result<(), String> {
|
||||
pub async fn set_as_default_browser() -> Result<SetDefaultOutcome, String> {
|
||||
let default_browser = DefaultBrowser::instance();
|
||||
default_browser.set_as_default_browser().await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The type system now prevents the mistake behind the crash on Windows.
|
||||
/// `SendMessageTimeoutW` comes from the `windows` crate, and its
|
||||
/// out-parameter is typed `Option<*mut usize>`, so a four byte slot no longer
|
||||
/// compiles. That guarantee holds only while the call goes through the crate.
|
||||
/// A hand-written declaration would bring back the whole class of bug in a
|
||||
/// form no compiler and no lint can see, so refuse one here.
|
||||
///
|
||||
/// This looks at the Windows module on every platform, because the module is
|
||||
/// compiled out everywhere else and would otherwise go unchecked on the
|
||||
/// runners that do most of the work.
|
||||
#[test]
|
||||
fn the_windows_module_declares_no_foreign_functions_by_hand() {
|
||||
const SOURCE: &str = include_str!("default_browser.rs");
|
||||
|
||||
let start = SOURCE
|
||||
.find("mod windows {")
|
||||
.expect("the Windows module was renamed; update this guard");
|
||||
let end = SOURCE
|
||||
.find("mod linux {")
|
||||
.expect("the Linux module was renamed; update this guard");
|
||||
assert!(
|
||||
start < end,
|
||||
"the module order changed; update this guard so it still reads the Windows module"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!SOURCE[start..end].contains(r#"extern ""#),
|
||||
"The Windows module declares a foreign function by hand. Do not. A \
|
||||
hand-written declaration of SendMessageTimeoutA, with its out-parameter \
|
||||
typed *mut u32 instead of the real PDWORD_PTR, is what made Windows \
|
||||
write four bytes past a stack slot and kill the process every time a \
|
||||
user set Donut as their default browser. Take the binding from the \
|
||||
`windows` crate, which cannot drift from the real ABI, and add the \
|
||||
feature it needs to Cargo.toml."
|
||||
);
|
||||
}
|
||||
|
||||
/// Show why the out-parameter has to be pointer sized.
|
||||
///
|
||||
/// This does not try to reproduce the crash. Whether the four byte overrun is
|
||||
/// fatal depends on the frame the optimiser happens to build, so a crash test
|
||||
/// passes under one profile and fails under another. It measures the thing
|
||||
/// that is always true instead: the call writes eight bytes.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn send_message_timeout_writes_a_pointer_sized_result() {
|
||||
use windows::Win32::Foundation::{HWND, LPARAM, WPARAM};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{SendMessageTimeoutW, SMTO_ABORTIFHUNG, WM_NULL};
|
||||
|
||||
/// A four byte slot with a marker behind it, laid out the way the old code
|
||||
/// laid out its `u32`. Eight bytes in total and eight byte aligned, so a
|
||||
/// pointer sized write lands entirely inside the struct. Nothing outside it
|
||||
/// is touched and the test is not itself undefined behaviour.
|
||||
#[repr(C, align(8))]
|
||||
struct Probe {
|
||||
result: u32,
|
||||
canary: u32,
|
||||
}
|
||||
|
||||
const SENTINEL: u32 = 0xDEAD_BEEF;
|
||||
|
||||
let mut probe = Probe {
|
||||
result: SENTINEL,
|
||||
canary: SENTINEL,
|
||||
};
|
||||
|
||||
// The window handle is deliberately not a window. USER32 clears the
|
||||
// out-parameter before it looks at the target, so this measures the write
|
||||
// width without creating a window, without a message loop and without
|
||||
// sending anything to another process. The test is hermetic.
|
||||
unsafe {
|
||||
SendMessageTimeoutW(
|
||||
HWND(0xDEAD_0000_usize as *mut core::ffi::c_void),
|
||||
WM_NULL,
|
||||
WPARAM(0),
|
||||
LPARAM(0),
|
||||
SMTO_ABORTIFHUNG,
|
||||
50,
|
||||
Some(&mut probe as *mut Probe as *mut usize),
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
probe.result, 0,
|
||||
"SendMessageTimeoutW did not write the out-parameter at all, so this test \
|
||||
no longer measures anything. Check the call before trusting it."
|
||||
);
|
||||
assert_ne!(
|
||||
probe.canary, SENTINEL,
|
||||
"SendMessageTimeoutW wrote only four bytes. If Windows has really narrowed \
|
||||
lpdwResult to a DWORD then notify_system_of_changes may use a u32. Until \
|
||||
then the out-parameter stays pointer sized."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+359
-6
@@ -15,6 +15,10 @@ use crate::events;
|
||||
// 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);
|
||||
|
||||
// Sent on both the asset request and its checksum sidecar so the CDN sees one
|
||||
// consistent client for the pair.
|
||||
const DOWNLOAD_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36";
|
||||
|
||||
// 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>>> =
|
||||
@@ -233,6 +237,113 @@ impl Downloader {
|
||||
.await?;
|
||||
log::info!("Download URL resolved");
|
||||
|
||||
// Every browser asset is published with a `<asset>.sha256` sidecar. Fetch
|
||||
// it before the transfer starts: an asset nobody can verify costs one small
|
||||
// request to reject here, or a wasted gigabyte to reject later.
|
||||
let expected_sha256 = self
|
||||
.fetch_expected_archive_checksum(&download_url, browser_type.display_name(), version)
|
||||
.await?;
|
||||
|
||||
let file_path = self
|
||||
.stream_download(
|
||||
browser_type.clone(),
|
||||
version,
|
||||
&download_url,
|
||||
file_path,
|
||||
cancel_token,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Hashing a multi-gigabyte archive takes seconds, so tell the UI what the
|
||||
// pause is for instead of leaving the bar sitting at 100%.
|
||||
let _ = events::emit(
|
||||
"download-progress",
|
||||
&DownloadProgress {
|
||||
browser: browser_type.as_str().to_string(),
|
||||
version: version.to_string(),
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
percentage: 100.0,
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "verifying".to_string(),
|
||||
},
|
||||
);
|
||||
verify_archive_checksum(
|
||||
&file_path,
|
||||
&expected_sha256,
|
||||
browser_type.display_name(),
|
||||
version,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
/// Fetch `<asset>.sha256` and return the digest it publishes for the asset.
|
||||
/// Every failure mode maps to `BROWSER_CHECKSUM_UNAVAILABLE`; the specifics
|
||||
/// go to the log.
|
||||
async fn fetch_expected_archive_checksum(
|
||||
&self,
|
||||
download_url: &str,
|
||||
browser: &str,
|
||||
version: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let unavailable = || -> Box<dyn std::error::Error + Send + Sync> {
|
||||
serde_json::json!({
|
||||
"code": "BROWSER_CHECKSUM_UNAVAILABLE",
|
||||
"params": { "browser": browser, "version": version }
|
||||
})
|
||||
.to_string()
|
||||
.into()
|
||||
};
|
||||
|
||||
let sidecar_url = checksum_sidecar_url(download_url);
|
||||
let response = match self
|
||||
.client
|
||||
.get(&sidecar_url)
|
||||
.header("User-Agent", DOWNLOAD_USER_AGENT)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) if response.status().is_success() => response,
|
||||
Ok(response) => {
|
||||
log::warn!(
|
||||
"Checksum sidecar request failed for {browser} {version}: HTTP {}",
|
||||
response.status()
|
||||
);
|
||||
return Err(unavailable());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Checksum sidecar request failed for {browser} {version}: {e}");
|
||||
return Err(unavailable());
|
||||
}
|
||||
};
|
||||
|
||||
let sidecar_text = match response.text().await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read the checksum sidecar for {browser} {version}: {e}");
|
||||
return Err(unavailable());
|
||||
}
|
||||
};
|
||||
|
||||
let asset_name = asset_filename_from_url(download_url);
|
||||
let Some(expected) = crate::checksum::parse_sidecar_digest(&sidecar_text, asset_name) else {
|
||||
log::warn!("No usable digest for {asset_name} in {sidecar_url}");
|
||||
return Err(unavailable());
|
||||
};
|
||||
Ok(expected)
|
||||
}
|
||||
|
||||
async fn stream_download(
|
||||
&self,
|
||||
browser_type: BrowserType,
|
||||
version: &str,
|
||||
download_url: &str,
|
||||
file_path: PathBuf,
|
||||
cancel_token: Option<&CancellationToken>,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// In-session resume: a large (~1GB) download over a flaky connection can
|
||||
// drop mid-stream. Rather than surfacing the first stall/chunk error as a
|
||||
// terminal failure (which forces the user to re-click and risks the CDN
|
||||
@@ -257,11 +368,8 @@ impl Downloader {
|
||||
for attempt in 0..=max_send_retries {
|
||||
let mut request = self
|
||||
.client
|
||||
.get(&download_url)
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
);
|
||||
.get(download_url)
|
||||
.header("User-Agent", DOWNLOAD_USER_AGENT);
|
||||
|
||||
if existing_size > 0 {
|
||||
request = request.header("Range", format!("bytes={existing_size}-"));
|
||||
@@ -682,7 +790,7 @@ impl Downloader {
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
return Err(format!("Failed to download browser: {e}").into());
|
||||
return Err(contextualize("Failed to download browser", e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -872,6 +980,74 @@ impl Downloader {
|
||||
}
|
||||
}
|
||||
|
||||
/// Offset of a URL's query or fragment, or its length when it has neither.
|
||||
fn url_path_end(url: &str) -> usize {
|
||||
url.find(['?', '#']).unwrap_or(url.len())
|
||||
}
|
||||
|
||||
/// The `<asset>.sha256` published next to every browser asset. Any query or
|
||||
/// fragment stays at the end so a signed URL keeps working.
|
||||
fn checksum_sidecar_url(download_url: &str) -> String {
|
||||
let (base, suffix) = download_url.split_at(url_path_end(download_url));
|
||||
format!("{base}.sha256{suffix}")
|
||||
}
|
||||
|
||||
/// Last path segment of `url`. This is the name a `sha256sum` sidecar records,
|
||||
/// and it is not the local filename: the local one is built from the running
|
||||
/// platform, while this one is whatever the publisher called the asset.
|
||||
fn asset_filename_from_url(url: &str) -> &str {
|
||||
url[..url_path_end(url)].rsplit('/').next().unwrap_or("")
|
||||
}
|
||||
|
||||
/// Compare the finished archive against the digest published beside it. A
|
||||
/// mismatch means the bytes on disk are not the asset the manifest promised,
|
||||
/// so the file is deleted instead of being handed to the extractor.
|
||||
async fn verify_archive_checksum(
|
||||
file_path: &Path,
|
||||
expected: &str,
|
||||
browser: &str,
|
||||
version: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let hash_path = file_path.to_path_buf();
|
||||
let actual = tokio::task::spawn_blocking(move || crate::checksum::sha256_file(&hash_path))
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Checksum task failed: {e}").into()
|
||||
})??;
|
||||
|
||||
if actual.eq_ignore_ascii_case(expected) {
|
||||
log::info!("Checksum verified for {browser} {version}: {actual}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::error!("Checksum mismatch for {browser} {version}: expected {expected}, got {actual}");
|
||||
let _ = std::fs::remove_file(file_path);
|
||||
Err(
|
||||
serde_json::json!({
|
||||
"code": "BROWSER_CHECKSUM_MISMATCH",
|
||||
"params": { "browser": browser, "version": version }
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Prefix the calling context onto a bare message, but leave an already-coded
|
||||
/// backend error alone: `wrap_backend_error` only passes a payload through
|
||||
/// when it still starts with `{`, so prefixing one would strip the code and
|
||||
/// the frontend would fall back to the untranslated INTERNAL_ERROR text.
|
||||
fn contextualize(
|
||||
context: &str,
|
||||
e: impl std::fmt::Display,
|
||||
) -> Box<dyn std::error::Error + Send + Sync> {
|
||||
let msg = e.to_string();
|
||||
if msg.starts_with('{') {
|
||||
msg.into()
|
||||
} else {
|
||||
format!("{context}: {msg}").into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a specific browser-version pair is currently being downloaded
|
||||
pub fn is_downloading(browser: &str, version: &str) -> bool {
|
||||
let download_key = format!("{browser}-{version}");
|
||||
@@ -1040,6 +1216,183 @@ mod tests {
|
||||
assert_eq!(downloaded_content.len(), test_content.len());
|
||||
}
|
||||
|
||||
// Stand-in archive body for the checksum tests. Digests are computed from
|
||||
// it rather than hardcoded, so the fixture and the assertion cannot drift.
|
||||
const ARCHIVE_BODY: &[u8] = b"wayfern archive bytes";
|
||||
|
||||
fn digest_of(bytes: &[u8]) -> String {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let path = temp_dir.path().join("archive.zip");
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
crate::checksum::sha256_file(&path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checksum_sidecar_url_appends_to_the_asset_path() {
|
||||
assert_eq!(
|
||||
checksum_sidecar_url("https://download.wayfern.com/wayfern-151_windows_x64.zip"),
|
||||
"https://download.wayfern.com/wayfern-151_windows_x64.zip.sha256"
|
||||
);
|
||||
// A signed URL keeps its query, so the sidecar stays reachable.
|
||||
assert_eq!(
|
||||
checksum_sidecar_url("https://cdn.example.com/a.zip?token=abc&exp=1"),
|
||||
"https://cdn.example.com/a.zip.sha256?token=abc&exp=1"
|
||||
);
|
||||
assert_eq!(
|
||||
checksum_sidecar_url("https://cdn.example.com/a.zip#frag"),
|
||||
"https://cdn.example.com/a.zip.sha256#frag"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_asset_filename_from_url_takes_the_publisher_name() {
|
||||
// Deliberately different from the local filename, which is built from the
|
||||
// running platform and would never match a sidecar entry.
|
||||
assert_eq!(
|
||||
asset_filename_from_url("https://download.wayfern.com/wayfern-151.0.7922.71_windows_x64.zip"),
|
||||
"wayfern-151.0.7922.71_windows_x64.zip"
|
||||
);
|
||||
assert_eq!(
|
||||
asset_filename_from_url("https://cdn.example.com/dir/a.tar.xz?token=abc"),
|
||||
"a.tar.xz"
|
||||
);
|
||||
assert_eq!(asset_filename_from_url("https://cdn.example.com/"), "");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_expected_archive_checksum_reads_the_sidecar() {
|
||||
let server = MockServer::start().await;
|
||||
let downloader = Downloader::new_for_test();
|
||||
let digest = digest_of(ARCHIVE_BODY);
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/wayfern-151_windows_x64.zip.sha256"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(format!("{digest} wayfern-151_windows_x64.zip\n")),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let url = format!("{}/wayfern-151_windows_x64.zip", server.uri());
|
||||
let expected = downloader
|
||||
.fetch_expected_archive_checksum(&url, "wayfern", "151")
|
||||
.await
|
||||
.expect("sidecar should resolve");
|
||||
|
||||
assert_eq!(expected, digest);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_expected_archive_checksum_fails_when_the_sidecar_is_missing() {
|
||||
let server = MockServer::start().await;
|
||||
let downloader = Downloader::new_for_test();
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/wayfern-151_windows_x64.zip.sha256"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let url = format!("{}/wayfern-151_windows_x64.zip", server.uri());
|
||||
let error = downloader
|
||||
.fetch_expected_archive_checksum(&url, "wayfern", "151")
|
||||
.await
|
||||
.expect_err("an unverifiable asset must not be downloaded")
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
error.contains("BROWSER_CHECKSUM_UNAVAILABLE") && error.contains("151"),
|
||||
"expected a coded, translatable error, got: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_expected_archive_checksum_rejects_a_sidecar_without_a_digest() {
|
||||
let server = MockServer::start().await;
|
||||
let downloader = Downloader::new_for_test();
|
||||
|
||||
// A CDN that answers 200 with an error page must not be read as a digest.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/wayfern-151_windows_x64.zip.sha256"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("<!doctype html><title>404</title>"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let url = format!("{}/wayfern-151_windows_x64.zip", server.uri());
|
||||
let error = downloader
|
||||
.fetch_expected_archive_checksum(&url, "wayfern", "151")
|
||||
.await
|
||||
.expect_err("an unparsable sidecar must not pass")
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
error.contains("BROWSER_CHECKSUM_UNAVAILABLE"),
|
||||
"expected a coded error, got: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_verify_archive_checksum_accepts_a_matching_digest() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let archive = temp_dir.path().join("wayfern.zip");
|
||||
std::fs::write(&archive, ARCHIVE_BODY).unwrap();
|
||||
let digest = crate::checksum::sha256_file(&archive).unwrap();
|
||||
|
||||
// Case is normalized on both sides, so an uppercase sidecar still matches.
|
||||
verify_archive_checksum(
|
||||
&archive,
|
||||
&digest.to_ascii_uppercase(),
|
||||
"wayfern",
|
||||
"151.0.7922.71",
|
||||
)
|
||||
.await
|
||||
.expect("a matching digest should verify");
|
||||
|
||||
assert!(archive.exists(), "a verified archive must be kept");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_verify_archive_checksum_rejects_and_deletes_a_mismatch() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let archive = temp_dir.path().join("wayfern.zip");
|
||||
// The file on disk is the wrong asset entirely, which is what a mislinked
|
||||
// manifest slot delivers, while the sidecar describes the right one.
|
||||
std::fs::write(&archive, b"a macOS disk image, not a windows zip").unwrap();
|
||||
let expected = digest_of(ARCHIVE_BODY);
|
||||
|
||||
let error = verify_archive_checksum(&archive, &expected, "wayfern", "151.0.7922.71")
|
||||
.await
|
||||
.expect_err("a mismatched digest must fail")
|
||||
.to_string();
|
||||
|
||||
assert!(
|
||||
error.contains("BROWSER_CHECKSUM_MISMATCH") && error.contains("151.0.7922.71"),
|
||||
"expected a coded, translatable error, got: {error}"
|
||||
);
|
||||
assert!(
|
||||
!archive.exists(),
|
||||
"an archive that failed verification must be deleted, not extracted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contextualize_preserves_a_coded_backend_error() {
|
||||
// A coded payload must survive untouched: wrap_backend_error only passes
|
||||
// it through while it still starts with '{'.
|
||||
let coded = r#"{"code":"BROWSER_CHECKSUM_MISMATCH","params":{"browser":"wayfern"}}"#;
|
||||
assert_eq!(
|
||||
contextualize("Failed to download browser", coded).to_string(),
|
||||
coded
|
||||
);
|
||||
|
||||
// A bare message still gets its context.
|
||||
assert_eq!(
|
||||
contextualize("Failed to download browser", "connection reset").to_string(),
|
||||
"Failed to download browser: connection reset"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_download_state_for_browser_removes_stuck_keys() {
|
||||
// Simulate a download future that was abandoned without running its own cleanup,
|
||||
|
||||
@@ -112,7 +112,7 @@ impl LocaleSelector {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => {
|
||||
let name = e.name();
|
||||
let name_str = std::str::from_utf8(name.as_ref()).unwrap_or("");
|
||||
let name_str = name.as_ref();
|
||||
|
||||
if name_str == "territory" {
|
||||
if let Some(code) = current_territory.take() {
|
||||
@@ -122,8 +122,8 @@ impl LocaleSelector {
|
||||
}
|
||||
|
||||
for attr in e.attributes().flatten() {
|
||||
if attr.key.as_ref() == b"type" {
|
||||
current_territory = Some(String::from_utf8_lossy(&attr.value).to_uppercase());
|
||||
if attr.key.as_ref() == "type" {
|
||||
current_territory = Some(attr.value.to_uppercase());
|
||||
}
|
||||
}
|
||||
} else if name_str == "languagePopulation" && current_territory.is_some() {
|
||||
@@ -132,11 +132,11 @@ impl LocaleSelector {
|
||||
|
||||
for attr in e.attributes().flatten() {
|
||||
match attr.key.as_ref() {
|
||||
b"type" => {
|
||||
lang_type = Some(String::from_utf8_lossy(&attr.value).to_string());
|
||||
"type" => {
|
||||
lang_type = Some(attr.value.to_string());
|
||||
}
|
||||
b"populationPercent" => {
|
||||
pop_percent = String::from_utf8_lossy(&attr.value).parse().unwrap_or(0.0);
|
||||
"populationPercent" => {
|
||||
pop_percent = attr.value.parse().unwrap_or(0.0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -152,7 +152,7 @@ impl LocaleSelector {
|
||||
}
|
||||
Ok(Event::End(ref e)) => {
|
||||
let name_ref = e.name();
|
||||
let name = std::str::from_utf8(name_ref.as_ref()).unwrap_or("");
|
||||
let name = name_ref.as_ref();
|
||||
if name == "territory" {
|
||||
if let Some(code) = current_territory.take() {
|
||||
if !current_languages.is_empty() {
|
||||
|
||||
+182
-73
@@ -62,6 +62,7 @@ mod browser;
|
||||
mod browser_runner;
|
||||
mod browser_version_manager;
|
||||
mod cdp_target;
|
||||
mod checksum;
|
||||
mod default_browser;
|
||||
pub mod dns_blocklist;
|
||||
mod downloaded_browsers_registry;
|
||||
@@ -104,6 +105,7 @@ mod cloud_errors;
|
||||
mod commercial_license;
|
||||
mod cookie_bot;
|
||||
mod cookie_manager;
|
||||
mod cookie_paste;
|
||||
pub mod events;
|
||||
mod mcp_integrations;
|
||||
mod mcp_server;
|
||||
@@ -156,12 +158,12 @@ use settings_manager::{
|
||||
};
|
||||
|
||||
use sync::{
|
||||
cancel_profile_sync, check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities,
|
||||
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile,
|
||||
is_vpn_in_use_by_synced_profile, request_profile_sync, rollover_encryption_for_all_entities,
|
||||
set_e2e_password, set_extension_group_sync_enabled, set_extension_sync_enabled,
|
||||
set_group_sync_enabled, set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled,
|
||||
verify_e2e_password,
|
||||
cancel_profile_sync, check_has_e2e_password, check_sync_server_connection, delete_e2e_password,
|
||||
enable_sync_for_all_entities, get_unsynced_entity_counts, is_group_in_use_by_synced_profile,
|
||||
is_proxy_in_use_by_synced_profile, is_vpn_in_use_by_synced_profile, request_profile_sync,
|
||||
rollover_encryption_for_all_entities, set_e2e_password, set_extension_group_sync_enabled,
|
||||
set_extension_sync_enabled, set_group_sync_enabled, set_profile_sync_mode,
|
||||
set_proxy_sync_enabled, set_vpn_sync_enabled, verify_e2e_password,
|
||||
};
|
||||
|
||||
use tag_manager::get_all_tags;
|
||||
@@ -475,29 +477,57 @@ async fn copy_profile_cookies(
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Push a profile's freshly written cookies to the cloud, if it syncs at all.
|
||||
fn queue_profile_cookie_sync(profile_id: &str) {
|
||||
let Some(scheduler) = crate::sync::get_global_scheduler() else {
|
||||
return;
|
||||
};
|
||||
let Ok(profiles) = profile::manager::ProfileManager::instance().list_profiles() else {
|
||||
return;
|
||||
};
|
||||
let syncs = profiles
|
||||
.iter()
|
||||
.any(|p| p.id.to_string() == profile_id && p.is_sync_enabled());
|
||||
if !syncs {
|
||||
return;
|
||||
}
|
||||
let pid = profile_id.to_string();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
scheduler.queue_profile_sync(pid).await;
|
||||
});
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn import_cookies_from_file(
|
||||
async fn analyze_pasted_cookies(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
content: String,
|
||||
) -> Result<cookie_manager::CookieImportResult, String> {
|
||||
let result =
|
||||
cookie_manager::CookieManager::import_cookies(&app_handle, &profile_id, &content).await?;
|
||||
site: Option<String>,
|
||||
) -> Result<cookie_manager::CookiePasteAnalysis, String> {
|
||||
cookie_manager::CookieManager::analyze_paste(&app_handle, &profile_id, &content, site.as_deref())
|
||||
.await
|
||||
}
|
||||
|
||||
// Trigger sync for the profile if sync is enabled
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
let profile_manager = profile::manager::ProfileManager::instance();
|
||||
if let Ok(profiles) = profile_manager.list_profiles() {
|
||||
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) {
|
||||
if profile.is_sync_enabled() {
|
||||
let pid = profile_id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
scheduler.queue_profile_sync(pid).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[tauri::command]
|
||||
async fn import_pasted_cookies(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
content: String,
|
||||
site: Option<String>,
|
||||
mode: cookie_manager::CookieWriteMode,
|
||||
include_expired: bool,
|
||||
) -> Result<cookie_manager::CookiePasteImportResult, String> {
|
||||
let result = cookie_manager::CookieManager::import_paste(
|
||||
&app_handle,
|
||||
&profile_id,
|
||||
&content,
|
||||
site.as_deref(),
|
||||
mode,
|
||||
include_expired,
|
||||
)
|
||||
.await?;
|
||||
|
||||
queue_profile_cookie_sync(&profile_id);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -1263,13 +1293,27 @@ async fn list_active_vpn_connections() -> Result<Vec<vpn::VpnStatus>, String> {
|
||||
)
|
||||
}
|
||||
|
||||
/// What the fingerprint form gets back from `generate_sample_fingerprint`.
|
||||
///
|
||||
/// The identity fields are `None` on a browser without the identity API. They
|
||||
/// have to travel with the fingerprint rather than be re-derived later: the
|
||||
/// form writes all three into the profile's Wayfern config in one edit, and a
|
||||
/// fingerprint stored without its identity is one the launch path would throw
|
||||
/// away and mint again.
|
||||
#[derive(serde::Serialize)]
|
||||
struct SampleFingerprint {
|
||||
fingerprint: String,
|
||||
identity_id: Option<String>,
|
||||
identity_baseline: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn generate_sample_fingerprint(
|
||||
app_handle: tauri::AppHandle,
|
||||
browser: String,
|
||||
version: String,
|
||||
config_json: String,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<SampleFingerprint, String> {
|
||||
let temp_profile = crate::profile::BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "temp_fingerprint_gen".to_string(),
|
||||
@@ -1309,7 +1353,11 @@ async fn generate_sample_fingerprint(
|
||||
manager
|
||||
.generate_fingerprint_config(&app_handle, &temp_profile, &config)
|
||||
.await
|
||||
.map(|(fingerprint, _geolocation_applied)| fingerprint)
|
||||
.map(|generated| SampleFingerprint {
|
||||
fingerprint: generated.fingerprint,
|
||||
identity_id: generated.identity_id,
|
||||
identity_baseline: generated.identity_baseline,
|
||||
})
|
||||
.map_err(|e| format!("Failed to generate fingerprint: {e}"))
|
||||
} else {
|
||||
Err(format!(
|
||||
@@ -1697,6 +1745,44 @@ fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::E
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pick the things to open out of a command line.
|
||||
///
|
||||
/// This is how the desktop hands a browser its work. Windows and Linux both
|
||||
/// start the executable with the target as an argument: a URL for a link, and a
|
||||
/// plain path for a file, because the ProgId command in the registry passes
|
||||
/// `%1` through unchanged. A path becomes a `file://` URL here, so callers only
|
||||
/// ever deal with URLs.
|
||||
///
|
||||
/// A path that does not exist is ignored. Guessing at one would turn a stray
|
||||
/// flag into a navigation. The first argument is the executable's own path and
|
||||
/// is never a target.
|
||||
fn urls_from_args<'a>(args: impl IntoIterator<Item = &'a String>) -> Vec<String> {
|
||||
args
|
||||
.into_iter()
|
||||
.skip(1)
|
||||
.filter_map(|arg| {
|
||||
if arg.starts_with("http://") || arg.starts_with("https://") {
|
||||
return Some(arg.clone());
|
||||
}
|
||||
|
||||
let path = std::path::Path::new(arg);
|
||||
if !path.is_file() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
env::current_dir().ok()?.join(path)
|
||||
};
|
||||
|
||||
url::Url::from_file_path(absolute)
|
||||
.ok()
|
||||
.map(|url| url.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
run_with_builder(|builder| builder);
|
||||
@@ -1707,7 +1793,7 @@ pub fn run_with_builder(
|
||||
configure_builder: impl FnOnce(tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry>,
|
||||
) {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let startup_url = args.iter().find(|arg| arg.starts_with("http")).cloned();
|
||||
let startup_url = urls_from_args(args.iter()).into_iter().next();
|
||||
|
||||
if let Some(url) = startup_url.clone() {
|
||||
log::info!("Found startup URL in command line");
|
||||
@@ -1771,6 +1857,20 @@ pub fn run_with_builder(
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
}
|
||||
|
||||
// A second launch is how the desktop hands a running browser its next
|
||||
// link. The shell starts the executable with the target in argv, this
|
||||
// callback receives that argv, and the second process exits. The callback
|
||||
// used to log the arguments and drop them, so clicking a link did nothing
|
||||
// whenever Donut was already open, which is every time after the first.
|
||||
for url in urls_from_args(args.iter()) {
|
||||
let handle = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = handle_url_open(handle, url).await {
|
||||
log::error!("Failed to handle a forwarded URL: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
@@ -2572,51 +2672,7 @@ pub fn run_with_builder(
|
||||
// Start sync subscription and scheduler if configured
|
||||
let app_handle_sync = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
use std::sync::Arc;
|
||||
|
||||
let mut subscription_manager = sync::SubscriptionManager::new();
|
||||
let work_rx = subscription_manager.take_work_receiver();
|
||||
|
||||
if let Err(e) = subscription_manager.start(app_handle_sync.clone()).await {
|
||||
log::warn!("Failed to start sync subscription: {e}");
|
||||
}
|
||||
|
||||
if let Some(work_rx) = work_rx {
|
||||
let scheduler = Arc::new(sync::SyncScheduler::new());
|
||||
|
||||
// Set the global scheduler so commands can access it
|
||||
sync::set_global_scheduler(scheduler.clone());
|
||||
|
||||
// Start initial sync for all enabled profiles
|
||||
scheduler.sync_all_enabled_profiles(&app_handle_sync).await;
|
||||
|
||||
// Check for missing synced profiles (deleted locally but exist remotely)
|
||||
match sync::SyncEngine::create_from_settings(&app_handle_sync).await {
|
||||
Ok(engine) => {
|
||||
if let Err(e) = engine
|
||||
.check_for_missing_synced_profiles(&app_handle_sync)
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to check for missing profiles: {}", e);
|
||||
}
|
||||
if let Err(e) = engine
|
||||
.check_for_missing_synced_entities(&app_handle_sync)
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to check for missing entities: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Sync not configured, skipping missing profile check: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
scheduler
|
||||
.clone()
|
||||
.start(app_handle_sync.clone(), work_rx)
|
||||
.await;
|
||||
log::info!("Sync scheduler started");
|
||||
}
|
||||
sync::start_pipeline(app_handle_sync).await;
|
||||
});
|
||||
|
||||
// Start cloud auth background refresh loop
|
||||
@@ -2780,6 +2836,7 @@ pub fn run_with_builder(
|
||||
validate_vless_uri,
|
||||
get_sync_settings,
|
||||
save_sync_settings,
|
||||
check_sync_server_connection,
|
||||
set_profile_sync_mode,
|
||||
cancel_profile_sync,
|
||||
request_profile_sync,
|
||||
@@ -2801,7 +2858,8 @@ pub fn run_with_builder(
|
||||
read_profile_cookies,
|
||||
get_profile_cookie_stats,
|
||||
copy_profile_cookies,
|
||||
import_cookies_from_file,
|
||||
analyze_pasted_cookies,
|
||||
import_pasted_cookies,
|
||||
export_profile_cookies,
|
||||
check_wayfern_terms_accepted,
|
||||
check_wayfern_downloaded,
|
||||
@@ -2915,6 +2973,57 @@ pub fn run_with_builder(
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn a_command_line_yields_the_links_and_files_it_carries() {
|
||||
let exe = "C:/Program Files/Donut Browser/donutbrowser.exe".to_string();
|
||||
|
||||
// The executable's own path leads every command line and is not a target,
|
||||
// even on a machine where that path happens to exist.
|
||||
assert!(super::urls_from_args([&exe]).is_empty());
|
||||
|
||||
let link = "https://example.com/a?b=c".to_string();
|
||||
let insecure = "http://example.com".to_string();
|
||||
assert_eq!(
|
||||
super::urls_from_args([&exe, &link, &insecure]),
|
||||
vec![link.clone(), insecure]
|
||||
);
|
||||
|
||||
// Flags and stray words are not links. The old filter took anything
|
||||
// starting with "http", which is looser than it looks.
|
||||
let flag = "--headless".to_string();
|
||||
let near_miss = "httpsomething".to_string();
|
||||
assert_eq!(
|
||||
super::urls_from_args([&exe, &flag, &near_miss]),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
|
||||
// A path that is not there is ignored rather than guessed at.
|
||||
let missing = "C:/no/such/page.html".to_string();
|
||||
assert!(super::urls_from_args([&exe, &missing]).is_empty());
|
||||
|
||||
// Explorer hands a browser a bare path, not a URL, because the registered
|
||||
// command passes `%1` straight through. Turning it into a `file://` URL
|
||||
// here is what makes the .html association in `default_browser.rs` a real
|
||||
// claim rather than an empty one.
|
||||
let directory = tempfile::tempdir().expect("temp dir");
|
||||
let page = directory.path().join("page.html");
|
||||
fs::write(&page, "<html></html>").expect("write the page");
|
||||
let page_arg = page.to_string_lossy().to_string();
|
||||
|
||||
let found = super::urls_from_args([&exe, &page_arg]);
|
||||
assert_eq!(found.len(), 1, "the file should have produced one URL");
|
||||
assert!(
|
||||
found[0].starts_with("file:///"),
|
||||
"expected a file URL, got {}",
|
||||
found[0]
|
||||
);
|
||||
assert!(
|
||||
found[0].ends_with("page.html"),
|
||||
"expected the page's own name, got {}",
|
||||
found[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_error_helpers_preserve_codes_and_structure_diagnostics() {
|
||||
let coded = super::backend_error("PROFILE_NOT_FOUND");
|
||||
|
||||
@@ -223,9 +223,14 @@ impl ProfileManager {
|
||||
.generate_fingerprint_config(app_handle, &temp_profile, &config)
|
||||
.await
|
||||
{
|
||||
Ok((generated_fingerprint, geo_applied)) => {
|
||||
config.fingerprint = Some(generated_fingerprint);
|
||||
geolocation_applied = geo_applied;
|
||||
Ok(generated) => {
|
||||
config.fingerprint = Some(generated.fingerprint);
|
||||
// Set together with the fingerprint they describe. A profile that
|
||||
// stored one without the other would either lose reproducibility or
|
||||
// diff its whole device into overrides on the next launch.
|
||||
config.identity_id = generated.identity_id;
|
||||
config.identity_baseline = generated.identity_baseline;
|
||||
geolocation_applied = generated.geolocation_applied;
|
||||
log::info!("Successfully generated fingerprint for Wayfern profile: {name}");
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -628,6 +633,36 @@ impl ProfileManager {
|
||||
return Err(format!("Browser version {version} is not downloaded").into());
|
||||
}
|
||||
|
||||
// A move back to a version without the identity API cannot carry an
|
||||
// identity-backed device with it, and leaving it in place is worse than
|
||||
// dropping it: the older browser would splice it onto a freshly drawn
|
||||
// device and persist the result, which then fails on every later launch.
|
||||
// Clearing `fingerprint` makes the next launch generate a fresh one;
|
||||
// `geo_proxy_signature` goes with it because it certifies location fields
|
||||
// of a fingerprint that no longer exists.
|
||||
//
|
||||
// Do NOT refuse the version change: `consolidate_browser_versions` only
|
||||
// reaches this direction once the newer binary is already gone from disk,
|
||||
// so refusing would strand the profile pointing at a missing executable.
|
||||
// Every other direction falls through untouched.
|
||||
let target_speaks_identity_api = crate::wayfern_manager::supports_identity_api(version);
|
||||
if let Some(cfg) = profile
|
||||
.wayfern_config
|
||||
.as_mut()
|
||||
.filter(|c| c.identity_id.is_some() && !target_speaks_identity_api)
|
||||
{
|
||||
cfg.identity_id = None;
|
||||
cfg.identity_baseline = None;
|
||||
cfg.fingerprint = None;
|
||||
cfg.geo_proxy_signature = None;
|
||||
log::warn!(
|
||||
"Profile '{}' moved from Wayfern {} to {}. Its stored fingerprint cannot be used there, so it was cleared and a fresh one will be generated on the next launch.",
|
||||
profile.name,
|
||||
profile.version,
|
||||
version
|
||||
);
|
||||
}
|
||||
|
||||
// Update version
|
||||
profile.version = version.to_string();
|
||||
|
||||
@@ -1101,6 +1136,11 @@ impl ProfileManager {
|
||||
// isolation between a clone and its source.
|
||||
if let Some(cfg) = new_profile.wayfern_config.as_mut() {
|
||||
cfg.fingerprint = None;
|
||||
// The identity is a stronger link than the payload: the same UUID rebuilds
|
||||
// the SAME device on any version, so a clone that kept it would stay
|
||||
// byte-identical to its source forever, not just until the next upgrade.
|
||||
cfg.identity_id = None;
|
||||
cfg.identity_baseline = None;
|
||||
}
|
||||
|
||||
self.save_profile(&new_profile)?;
|
||||
@@ -1116,7 +1156,7 @@ impl ProfileManager {
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: &str,
|
||||
config: WayfernConfig,
|
||||
mut config: WayfernConfig,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Find the profile by ID
|
||||
let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err(
|
||||
@@ -1148,6 +1188,18 @@ impl ProfileManager {
|
||||
);
|
||||
}
|
||||
|
||||
// The identity is internal state, so a caller that edits the fingerprint
|
||||
// through the API or MCP will not send it back. Dropping it would silently
|
||||
// re-mint the device on the next launch and throw the edit away with it,
|
||||
// which is the opposite of what an override is for. Carry it forward unless
|
||||
// the caller either supplied its own or cleared the fingerprint outright.
|
||||
if config.identity_id.is_none() && config.fingerprint.is_some() {
|
||||
if let Some(stored) = profile.wayfern_config.as_ref() {
|
||||
config.identity_id = stored.identity_id.clone();
|
||||
config.identity_baseline = stored.identity_baseline.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// Update the Wayfern configuration
|
||||
profile.wayfern_config = Some(config);
|
||||
|
||||
|
||||
@@ -988,7 +988,11 @@ impl ProfileImporter {
|
||||
{
|
||||
// geo_proxy_signature is intentionally left unset here: the first
|
||||
// launch's signature-mismatch refresh verifies the location either way.
|
||||
Ok((fp, _geolocation_applied)) => config.fingerprint = Some(fp),
|
||||
Ok(generated) => {
|
||||
config.fingerprint = Some(generated.fingerprint);
|
||||
config.identity_id = generated.identity_id;
|
||||
config.identity_baseline = generated.identity_baseline;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
|
||||
return Err(
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
use super::types::*;
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long to wait for a storage host to accept a connection.
|
||||
///
|
||||
/// This client had no timeouts at all. A host that neither accepts nor refuses,
|
||||
/// which is what a dropping firewall or a black-holed address looks like, held
|
||||
/// every attempt for the operating system's own connect backoff: measured at
|
||||
/// 21 s on Windows and 134 s on Linux. With `MAX_FILE_RETRIES` and its backoff
|
||||
/// that is minutes for one file, and a profile of two hundred files reports
|
||||
/// nothing for most of an hour.
|
||||
///
|
||||
/// Matches the pre-flight probe, so a host that fails the check fails a
|
||||
/// transfer the same way and in the same time.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
|
||||
/// How long a transfer may make no progress at all.
|
||||
///
|
||||
/// Deliberately an inactivity timeout and not a deadline on the whole request.
|
||||
/// Profile files run to tens of megabytes and a slow link is not a broken one,
|
||||
/// so a total timeout would start failing syncs that were working. This fires
|
||||
/// only when nothing arrives for a full minute.
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SyncClient {
|
||||
@@ -11,7 +33,15 @@ pub struct SyncClient {
|
||||
impl SyncClient {
|
||||
pub fn new(base_url: String, token: String) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
client: Client::builder()
|
||||
.connect_timeout(CONNECT_TIMEOUT)
|
||||
.read_timeout(READ_TIMEOUT)
|
||||
.build()
|
||||
// A builder failure here means the TLS backend did not start. The
|
||||
// default client cannot transfer either, so fall back and let the first
|
||||
// real request report it, rather than making this constructor fallible
|
||||
// for a condition no caller can act on.
|
||||
.unwrap_or_default(),
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
token,
|
||||
}
|
||||
@@ -234,10 +264,14 @@ impl SyncClient {
|
||||
}
|
||||
}
|
||||
|
||||
let response = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
|
||||
// The storage host here comes from the presigned URL, so on a self-hosted
|
||||
// server it is whatever the server signed against, frequently an address
|
||||
// only the server can resolve. `reqwest`'s own Display collapses that to
|
||||
// "error sending request", which is why this failure used to be
|
||||
// undiagnosable; report the innermost cause and the host it names.
|
||||
let response = req.send().await.map_err(|e| {
|
||||
SyncError::NetworkError(super::preflight::transport_reason_for(presigned_url, &e))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
@@ -251,12 +285,9 @@ impl SyncClient {
|
||||
}
|
||||
|
||||
pub async fn download_bytes(&self, presigned_url: &str) -> SyncResult<Vec<u8>> {
|
||||
let response = self
|
||||
.client
|
||||
.get(presigned_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
|
||||
let response = self.client.get(presigned_url).send().await.map_err(|e| {
|
||||
SyncError::NetworkError(super::preflight::transport_reason_for(presigned_url, &e))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(SyncError::NetworkError(format!(
|
||||
|
||||
@@ -134,12 +134,46 @@ fn critical_failure_message(action: &str, failures: &[(String, String)]) -> Stri
|
||||
|
||||
match failures.first() {
|
||||
Some((_, cause)) => format!(
|
||||
"Critical files failed to {action}: {files}. Cause: {cause}. Sync aborted to prevent data loss."
|
||||
"Critical files failed to {action}: {files}. Cause: {cause}.{hint} Sync aborted to prevent data loss.",
|
||||
hint = storage_endpoint_hint(cause)
|
||||
),
|
||||
None => format!("Critical files failed to {action}: {files}. Sync aborted to prevent data loss."),
|
||||
}
|
||||
}
|
||||
|
||||
/// The one fix worth naming when every transfer dies at connect.
|
||||
///
|
||||
/// Transfers go straight to the storage host named in the presigned URL, not
|
||||
/// through the sync server, so a self-hosted server that signs URLs against an
|
||||
/// address only it can resolve fails every file here while its own `/health`
|
||||
/// and `/readyz` stay green. The cause string names the host, which says which
|
||||
/// address is wrong; this line says where to change it, because the setting
|
||||
/// lives on the server, where the user is not looking.
|
||||
///
|
||||
/// The host only started appearing in that string when the transfer path moved
|
||||
/// to `transport_reason_for`. Before that this comment claimed a host that was
|
||||
/// never there, and every report of this bug arrived with a list of file names
|
||||
/// and nothing to act on.
|
||||
fn storage_endpoint_hint(cause: &str) -> String {
|
||||
let lowered = cause.to_ascii_lowercase();
|
||||
let is_transport_failure = [
|
||||
"connection failed",
|
||||
"timed out",
|
||||
"dns",
|
||||
"error sending request",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| lowered.contains(marker));
|
||||
|
||||
if is_transport_failure {
|
||||
" The storage host in the presigned URL could not be reached from this device. \
|
||||
On a self-hosted server, set S3_PUBLIC_ENDPOINT to an address this device can reach."
|
||||
.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that a manifest-supplied relative file path is safe to join onto a
|
||||
/// profile directory before writing/deleting. The manifest is remote-controlled
|
||||
/// (a self-hosted or compromised sync server, a MITM on a plaintext Regular-mode
|
||||
@@ -4292,6 +4326,106 @@ pub async fn rollover_encryption_for_all_entities(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The whole of issue 534, at the only place the user ever sees it.
|
||||
///
|
||||
/// A self-hosted server signs every presigned URL against the address it uses
|
||||
/// for storage itself. In the documented compose file that is a Docker
|
||||
/// service name, so the server is healthy, `/health` and `/readyz` are green,
|
||||
/// and the client cannot open a single one of the URLs it is handed. The
|
||||
/// reporters got a list of file names, no host and no setting, and there was
|
||||
/// nothing in it to act on.
|
||||
///
|
||||
/// The message has to carry three things: which files, which host refused
|
||||
/// them, and which setting fixes it.
|
||||
#[test]
|
||||
fn a_transfer_failure_names_the_host_and_the_setting_that_fixes_it() {
|
||||
// Exactly the text the transfer path now produces. The trailing host comes
|
||||
// from `preflight::transport_reason_for`, which the two transfer call sites
|
||||
// in `client.rs` use.
|
||||
let cause = "connection failed: No such host is known. (os error 11001) \
|
||||
(storage host minio:9000)";
|
||||
let failures = vec![
|
||||
("profile/Default/Cookies".to_string(), cause.to_string()),
|
||||
("profile/Default/Login Data".to_string(), cause.to_string()),
|
||||
("profile/Local State".to_string(), cause.to_string()),
|
||||
];
|
||||
|
||||
let message = critical_failure_message("upload", &failures);
|
||||
|
||||
assert!(
|
||||
message.contains("minio:9000"),
|
||||
"the reader has to learn which host refused the transfer: {message}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("S3_PUBLIC_ENDPOINT"),
|
||||
"the setting that fixes it lives on the server, so the message has to \
|
||||
name it: {message}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("profile/Default/Cookies"),
|
||||
"the affected files still belong in the message: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same guarantee, but driven through the real transfer path instead of a
|
||||
/// hand-written cause string.
|
||||
///
|
||||
/// The test above pins the message builder. This one pins the join: that an
|
||||
/// upload which cannot reach its host actually produces a cause carrying that
|
||||
/// host. Dropping back to a reason that omits the host, which is how this
|
||||
/// shipped for months, breaks this test and not the one above.
|
||||
///
|
||||
/// No server is involved. `.invalid` never resolves (RFC 2606), so the
|
||||
/// failure is the real one, offline and deterministic.
|
||||
#[tokio::test]
|
||||
async fn an_unreachable_storage_host_survives_the_whole_way_to_the_message() {
|
||||
let client = SyncClient::new("http://127.0.0.1:1".to_string(), "unused".to_string());
|
||||
let presigned = "http://donut-storage.invalid:9000/bucket/profiles/p1/Cookies\
|
||||
?X-Amz-Signature=deadbeef";
|
||||
|
||||
let error = client
|
||||
.upload_bytes(presigned, b"payload", None)
|
||||
.await
|
||||
.expect_err("a host that cannot resolve must not report a successful upload");
|
||||
|
||||
let message = critical_failure_message(
|
||||
"upload",
|
||||
&[("profile/Default/Cookies".to_string(), error.to_string())],
|
||||
);
|
||||
|
||||
assert!(
|
||||
message.contains("donut-storage.invalid:9000"),
|
||||
"the host has to survive from the transfer to the message: {message}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("S3_PUBLIC_ENDPOINT"),
|
||||
"an unreachable storage host has one fix, and it is on the server: {message}"
|
||||
);
|
||||
assert!(
|
||||
!message.contains("X-Amz-Signature"),
|
||||
"the signature must never reach the message: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The hint is for a transfer that never connected. A server that answered
|
||||
/// and refused is a different problem with a different fix, and pointing that
|
||||
/// user at their storage endpoint would send them the wrong way.
|
||||
#[test]
|
||||
fn a_rejected_transfer_is_not_blamed_on_the_storage_endpoint() {
|
||||
let failures = vec![(
|
||||
"profile/Default/Cookies".to_string(),
|
||||
"Upload failed with status 403 Forbidden: SignatureDoesNotMatch".to_string(),
|
||||
)];
|
||||
|
||||
let message = critical_failure_message("upload", &failures);
|
||||
|
||||
assert!(message.contains("SignatureDoesNotMatch"), "{message}");
|
||||
assert!(
|
||||
!message.contains("S3_PUBLIC_ENDPOINT"),
|
||||
"a 403 is not an unreachable host: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_critical_failure_message_carries_the_cause() {
|
||||
// A self-hosted server that hands out unreachable presigned URLs fails
|
||||
@@ -4326,6 +4460,43 @@ mod tests {
|
||||
assert!(message.contains("failed to download"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_critical_failure_message_names_the_storage_endpoint_fix() {
|
||||
// A self-hosted server that signs presigned URLs against a container-only
|
||||
// host fails every transfer at connect while the server itself looks
|
||||
// healthy. Naming the file and the socket error is not enough to find the
|
||||
// setting that fixes it.
|
||||
let failures = vec![(
|
||||
"Default/Cookies".to_string(),
|
||||
"Failed to upload Default/Cookies after 3 retries: connection failed: \
|
||||
failed to lookup address information for minio"
|
||||
.to_string(),
|
||||
)];
|
||||
|
||||
let message = critical_failure_message("upload", &failures);
|
||||
assert!(message.contains("S3_PUBLIC_ENDPOINT"), "{message}");
|
||||
assert!(message.contains("could not be reached from this device"));
|
||||
assert!(message.contains("Sync aborted to prevent data loss."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_critical_failure_message_omits_the_hint_for_non_transport_causes() {
|
||||
// A rejected signature or a full disk is not a routing problem, and
|
||||
// pointing those users at S3_PUBLIC_ENDPOINT sends them the wrong way.
|
||||
for cause in [
|
||||
"Upload failed with status 403: SignatureDoesNotMatch",
|
||||
"Upload failed with status 507: quota exceeded",
|
||||
"No space left on device",
|
||||
] {
|
||||
let failures = vec![("Default/Cookies".to_string(), cause.to_string())];
|
||||
let message = critical_failure_message("upload", &failures);
|
||||
assert!(
|
||||
!message.contains("S3_PUBLIC_ENDPOINT"),
|
||||
"hint must not fire for: {cause}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_safe_manifest_path() {
|
||||
// Legitimate profile-relative paths are accepted.
|
||||
|
||||
@@ -2,6 +2,7 @@ mod client;
|
||||
pub mod encryption;
|
||||
mod engine;
|
||||
pub mod manifest;
|
||||
pub mod preflight;
|
||||
pub mod scheduler;
|
||||
pub mod subscription;
|
||||
pub mod types;
|
||||
@@ -25,10 +26,104 @@ pub use manifest::{
|
||||
compute_diff, compute_diff_with_bias, generate_manifest, DiffBias, HashCache, ManifestDiff,
|
||||
SyncManifest,
|
||||
};
|
||||
pub use preflight::{check_sync_server, check_sync_server_connection, SyncServerCheck};
|
||||
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
|
||||
pub use subscription::{SubscriptionManager, SyncWorkItem};
|
||||
pub use types::{SyncError, SyncResult};
|
||||
|
||||
/// The live subscription, held so it can be stopped.
|
||||
///
|
||||
/// It used to be a local inside whichever task built the pipeline. Dropping a
|
||||
/// `SubscriptionManager` does not end its work: `SyncSubscription::start`
|
||||
/// spawns a task holding clones of the running flag and the work sender, so the
|
||||
/// task outlived the handle and nothing could reach it. Every restart added one
|
||||
/// more live SSE connection, each with its own poll loop on the server, and
|
||||
/// disconnecting left an authenticated stream open to a server the user had
|
||||
/// just removed.
|
||||
static GLOBAL_SUBSCRIPTION: std::sync::Mutex<Option<SubscriptionManager>> =
|
||||
std::sync::Mutex::new(None);
|
||||
|
||||
/// Held for the whole of `start_pipeline`, so only one pipeline is ever being
|
||||
/// assembled at a time.
|
||||
static PIPELINE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
/// Retire the running pipeline, both halves of it.
|
||||
pub fn stop_pipeline() {
|
||||
if let Some(scheduler) = get_global_scheduler() {
|
||||
scheduler.stop();
|
||||
}
|
||||
if let Ok(mut guard) = GLOBAL_SUBSCRIPTION.lock() {
|
||||
if let Some(subscription) = guard.as_mut() {
|
||||
subscription.stop();
|
||||
}
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Build and start the sync pipeline. Safe to call again to restart it.
|
||||
///
|
||||
/// Startup and `restart_sync_service` each held their own copy of this, and the
|
||||
/// copies had drifted. The restart copy stopped the old scheduler first and then
|
||||
/// returned early if the subscription failed to start, so it left the
|
||||
/// application holding a scheduler whose task had already exited. Everything
|
||||
/// queued afterwards went into `pending_profiles` and was never drained, and
|
||||
/// sync was silently dead until the app was restarted. One function cannot
|
||||
/// drift from itself.
|
||||
pub async fn start_pipeline(app_handle: tauri::AppHandle) {
|
||||
// Two restarts arriving together would otherwise interleave: the second
|
||||
// retires what the first has not published yet, then both start, and one
|
||||
// scheduler is left ticking with nothing able to reach it. Building the
|
||||
// pipeline is rare and already awaits the network, so serialising it costs
|
||||
// nothing worth measuring.
|
||||
let _building = PIPELINE_LOCK.lock().await;
|
||||
|
||||
stop_pipeline();
|
||||
|
||||
let mut subscription_manager = SubscriptionManager::new();
|
||||
let Some(work_rx) = subscription_manager.take_work_receiver() else {
|
||||
log::error!("Sync pipeline has no work receiver; not starting");
|
||||
return;
|
||||
};
|
||||
|
||||
// A subscription failure costs live updates from other devices. It does not
|
||||
// stop this device syncing its own changes on the timer, so carry on. The
|
||||
// restart path used to give up here, which turned a token hiccup into sync
|
||||
// being dead until the next launch.
|
||||
if let Err(e) = subscription_manager.start(app_handle.clone()).await {
|
||||
log::warn!("Failed to start sync subscription, continuing without live updates: {e}");
|
||||
}
|
||||
if let Ok(mut guard) = GLOBAL_SUBSCRIPTION.lock() {
|
||||
*guard = Some(subscription_manager);
|
||||
}
|
||||
|
||||
let scheduler = std::sync::Arc::new(SyncScheduler::new());
|
||||
// Published before the loop starts, because the checks below await the
|
||||
// network and anything queued in the meantime has to land in this scheduler.
|
||||
// `stop()` marks it cancelled, so a restart arriving during that window still
|
||||
// retires it and `start` below becomes a no-op.
|
||||
set_global_scheduler(scheduler.clone());
|
||||
|
||||
scheduler.sync_all_enabled_profiles(&app_handle).await;
|
||||
|
||||
match SyncEngine::create_from_settings(&app_handle).await {
|
||||
Ok(engine) => {
|
||||
if let Err(e) = engine.check_for_missing_synced_profiles(&app_handle).await {
|
||||
log::warn!("Failed to check for missing profiles: {e}");
|
||||
}
|
||||
if let Err(e) = engine.check_for_missing_synced_entities(&app_handle).await {
|
||||
log::warn!("Failed to check for missing entities: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Sync not configured, skipping missing profile check: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
if scheduler.clone().start(app_handle, work_rx).await {
|
||||
log::info!("Sync scheduler started");
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a profile sync if the profile has sync enabled. No-op otherwise.
|
||||
///
|
||||
/// Called from profile metadata update paths so a rename / tag edit / proxy
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
//! Pre-flight check for a sync server, run from the network stack that
|
||||
//! actually performs transfers.
|
||||
//!
|
||||
//! A self-hosted server almost always reaches its storage over an address only
|
||||
//! it can resolve: the documented compose file points `S3_ENDPOINT` at
|
||||
//! `http://minio:9000`, a Docker service name that exists on the compose
|
||||
//! network and nowhere else. Presigned URLs are signed against the host they
|
||||
//! name, so every URL handed to this device names a host it cannot open. The
|
||||
//! server is healthy, `/health` and `/readyz` are green, and every single file
|
||||
//! transfer fails at connect.
|
||||
//!
|
||||
//! Checking the server alone is what let that configuration look correct. This
|
||||
//! module also opens the storage host the server says it hands out, from here,
|
||||
//! with the same client the uploader uses, so the break is named at the moment
|
||||
//! the user configures sync instead of after the first sync fails.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Both probes are liveness questions, not transfers, so they must fail fast
|
||||
/// rather than sit on a connect that is never going to answer.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
|
||||
/// What a pre-flight found. Every field is reported rather than collapsed into
|
||||
/// one boolean: "the server answers but its storage is unreachable from here"
|
||||
/// is a different problem with a different fix than "the server is down", and
|
||||
/// the UI has to be able to say which one happened.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub struct SyncServerCheck {
|
||||
/// The sync server itself answered.
|
||||
pub server_reachable: bool,
|
||||
/// The server reports it can reach its own storage. `None` when the server
|
||||
/// is too old to serve `/readyz`, which is a working server, not a broken
|
||||
/// one.
|
||||
pub storage_ready: Option<bool>,
|
||||
/// The host the server signs into presigned URLs, when it discloses one.
|
||||
/// Withheld by cloud deployments on purpose.
|
||||
pub storage_endpoint: Option<String>,
|
||||
/// Whether that host answered *this device*. `None` when there was nothing
|
||||
/// to probe.
|
||||
pub storage_reachable: Option<bool>,
|
||||
/// Why the storage probe failed, for the log and the error surface.
|
||||
pub storage_error: Option<String>,
|
||||
}
|
||||
|
||||
impl SyncServerCheck {
|
||||
/// Whether sync can actually move bytes. A green server with an unreachable
|
||||
/// storage host is the exact state this check exists to stop reporting as
|
||||
/// success.
|
||||
pub fn is_usable(&self) -> bool {
|
||||
self.server_reachable
|
||||
&& self.storage_ready != Some(false)
|
||||
&& self.storage_reachable != Some(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `/readyz` body. Every field is optional: older servers answer `/health`
|
||||
/// only, and cloud deployments withhold `storageEndpoint`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReadyzBody {
|
||||
#[serde(default)]
|
||||
s3: Option<bool>,
|
||||
#[serde(default, rename = "storageEndpoint")]
|
||||
storage_endpoint: Option<String>,
|
||||
}
|
||||
|
||||
fn probe_client() -> reqwest::Client {
|
||||
// Matches how `SyncClient` builds its client, so a TLS trust or proxy
|
||||
// condition that would fail an upload fails the probe the same way. A probe
|
||||
// that is more permissive than the uploader would report a working setup for
|
||||
// a configuration that cannot transfer.
|
||||
reqwest::Client::builder()
|
||||
.timeout(PROBE_TIMEOUT)
|
||||
.build()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Ask the sync server about itself, then verify the storage host it names.
|
||||
pub async fn check_sync_server(server_url: &str) -> SyncServerCheck {
|
||||
let base = server_url.trim().trim_end_matches('/');
|
||||
if base.is_empty() {
|
||||
return SyncServerCheck::default();
|
||||
}
|
||||
|
||||
let client = probe_client();
|
||||
let mut check = SyncServerCheck::default();
|
||||
|
||||
let readyz = match client.get(format!("{base}/readyz")).send().await {
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
log::warn!("Sync pre-flight: {base}/readyz did not answer: {e}");
|
||||
return check;
|
||||
}
|
||||
};
|
||||
|
||||
if readyz.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
// Predates /readyz. It is still a working server, so fall back rather than
|
||||
// failing a healthy setup, and leave the storage fields unknown.
|
||||
check.server_reachable = matches!(
|
||||
client.get(format!("{base}/health")).send().await,
|
||||
Ok(health) if health.status().is_success()
|
||||
);
|
||||
return check;
|
||||
}
|
||||
|
||||
// A 503 from /readyz is the server telling us its storage is down. That is a
|
||||
// reachable server with a real diagnosis in the body, so read it rather than
|
||||
// discarding it as a failed request.
|
||||
check.server_reachable = readyz.status().is_success() || readyz.status().as_u16() == 503;
|
||||
if !check.server_reachable {
|
||||
return check;
|
||||
}
|
||||
|
||||
let body = readyz.json::<ReadyzBody>().await.ok();
|
||||
check.storage_ready = body.as_ref().and_then(|b| b.s3);
|
||||
check.storage_endpoint = body.and_then(|b| b.storage_endpoint);
|
||||
|
||||
if let Some(endpoint) = check.storage_endpoint.clone() {
|
||||
match probe_storage_endpoint(&client, &endpoint).await {
|
||||
Ok(()) => check.storage_reachable = Some(true),
|
||||
Err(e) => {
|
||||
log::warn!("Sync pre-flight: storage endpoint {endpoint} is unreachable from here: {e}");
|
||||
check.storage_reachable = Some(false);
|
||||
check.storage_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
check
|
||||
}
|
||||
|
||||
/// Open the storage host and report only whether it answered.
|
||||
///
|
||||
/// ANY HTTP status counts as reachable, including 403 and 404. An unsigned GET
|
||||
/// of a bucket root is supposed to be refused; being refused proves DNS, TCP
|
||||
/// and TLS all worked, which is the entire question. Only a transport error
|
||||
/// means the presigned URLs cannot be opened from this device.
|
||||
async fn probe_storage_endpoint(client: &reqwest::Client, endpoint: &str) -> Result<(), String> {
|
||||
match client.get(endpoint).send().await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(transport_reason(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A short reason for a failed request.
|
||||
///
|
||||
/// `reqwest::Error`'s own `Display` is one line about the request and hides the
|
||||
/// cause chain, so a DNS failure reads as "error sending request" — the exact
|
||||
/// uninformative text that made this class of failure undiagnosable in the
|
||||
/// first place. Walk to the innermost source instead.
|
||||
///
|
||||
/// Shared with the transfer path so a failed upload and a failed probe describe
|
||||
/// the same network condition in the same words.
|
||||
pub(crate) fn transport_reason(error: &reqwest::Error) -> String {
|
||||
let kind = if error.is_timeout() {
|
||||
"timed out"
|
||||
} else if error.is_connect() {
|
||||
"connection failed"
|
||||
} else {
|
||||
"request failed"
|
||||
};
|
||||
|
||||
let mut source: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(error);
|
||||
let mut innermost: Option<String> = None;
|
||||
while let Some(cause) = source {
|
||||
innermost = Some(cause.to_string());
|
||||
source = cause.source();
|
||||
}
|
||||
|
||||
match innermost {
|
||||
Some(detail) => format!("{kind}: {detail}"),
|
||||
None => kind.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The same reason, naming the host that would not answer.
|
||||
///
|
||||
/// A transfer goes straight to the host inside the presigned URL, and that host
|
||||
/// is chosen by the server, not by this device. It is therefore the one fact the
|
||||
/// user has never seen and the only one that points at the fix. Leaving it out
|
||||
/// is what produced reports of "connection failed" with nothing to act on.
|
||||
///
|
||||
/// `reqwest::Error::url()` is empty for connect-stage failures, which are
|
||||
/// exactly the ones that matter here, so take the host from the URL the caller
|
||||
/// already holds.
|
||||
pub(crate) fn transport_reason_for(url: &str, error: &reqwest::Error) -> String {
|
||||
let reason = transport_reason(error);
|
||||
match storage_host(url) {
|
||||
Some(host) => format!("{reason} (storage host {host})"),
|
||||
None => reason,
|
||||
}
|
||||
}
|
||||
|
||||
/// Host and port, and nothing else.
|
||||
///
|
||||
/// A presigned URL carries the signature and the object key in its query, and
|
||||
/// this string reaches log files and toasts. Only the authority is safe to
|
||||
/// repeat, and it is the whole of what the reader needs.
|
||||
fn storage_host(url: &str) -> Option<String> {
|
||||
let parsed = url::Url::parse(url).ok()?;
|
||||
let host = parsed.host_str()?;
|
||||
match parsed.port() {
|
||||
Some(port) => Some(format!("{host}:{port}")),
|
||||
None => Some(host.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-flight a sync server before saving it, and before trusting it to sync.
|
||||
#[tauri::command]
|
||||
pub async fn check_sync_server_connection(server_url: String) -> Result<SyncServerCheck, String> {
|
||||
Ok(check_sync_server(&server_url).await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unreachable_storage_is_not_usable() {
|
||||
// The shape that used to report as healthy: server up, server's own
|
||||
// storage fine, and the host it hands to clients resolving nowhere but the
|
||||
// compose network.
|
||||
let check = SyncServerCheck {
|
||||
server_reachable: true,
|
||||
storage_ready: Some(true),
|
||||
storage_endpoint: Some("http://minio:9000".to_string()),
|
||||
storage_reachable: Some(false),
|
||||
storage_error: Some("connection failed: dns error".to_string()),
|
||||
};
|
||||
assert!(!check.is_usable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reachable_storage_is_usable() {
|
||||
let check = SyncServerCheck {
|
||||
server_reachable: true,
|
||||
storage_ready: Some(true),
|
||||
storage_endpoint: Some("http://localhost:9101".to_string()),
|
||||
storage_reachable: Some(true),
|
||||
storage_error: None,
|
||||
};
|
||||
assert!(check.is_usable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_without_readyz_is_usable() {
|
||||
// A server old enough to predate /readyz discloses nothing about storage.
|
||||
// Unknown must not read as broken, or every older self-hosted server would
|
||||
// start reporting a failure it does not have.
|
||||
let check = SyncServerCheck {
|
||||
server_reachable: true,
|
||||
storage_ready: None,
|
||||
storage_endpoint: None,
|
||||
storage_reachable: None,
|
||||
storage_error: None,
|
||||
};
|
||||
assert!(check.is_usable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_reporting_its_own_storage_down_is_not_usable() {
|
||||
let check = SyncServerCheck {
|
||||
server_reachable: true,
|
||||
storage_ready: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!check.is_usable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreachable_server_is_not_usable() {
|
||||
assert!(!SyncServerCheck::default().is_usable());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_url_reports_unreachable_without_a_request() {
|
||||
assert_eq!(check_sync_server(" ").await, SyncServerCheck::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unresolvable_storage_host_is_reported_with_a_cause() {
|
||||
// Exercises the real probe against a host that cannot resolve, which is
|
||||
// what a container-only endpoint looks like from the desktop.
|
||||
let client = probe_client();
|
||||
let error = probe_storage_endpoint(&client, "http://minio.invalid:9000")
|
||||
.await
|
||||
.expect_err("an unresolvable host must not report as reachable");
|
||||
assert!(
|
||||
error.contains("failed") || error.contains("timed out"),
|
||||
"unexpected reason: {error}"
|
||||
);
|
||||
// The bare reqwest Display is what this exists to avoid.
|
||||
assert_ne!(error, "error sending request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_presigned_url_yields_only_its_authority() {
|
||||
// The query carries the signature and the key. Neither may reach a log.
|
||||
let signed = "http://minio:9000/donut/profiles/p1/profile/Default/Cookies\
|
||||
?X-Amz-Signature=deadbeef&X-Amz-Credential=minioadmin";
|
||||
assert_eq!(storage_host(signed).as_deref(), Some("minio:9000"));
|
||||
|
||||
assert_eq!(
|
||||
storage_host("https://storage.example.com/bucket/key").as_deref(),
|
||||
Some("storage.example.com")
|
||||
);
|
||||
assert_eq!(storage_host("not a url").as_deref(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_transfer_names_the_host_that_refused_it() {
|
||||
// The whole point of the message. Issue 534 reporters saw a list of file
|
||||
// names and a bare "connection failed", and could not tell that the host
|
||||
// their server had signed into every URL was one only the server could
|
||||
// resolve.
|
||||
let url = "http://minio.invalid:9000/donut/profiles/p1/Cookies?X-Amz-Signature=abc";
|
||||
let error = probe_client()
|
||||
.put(url)
|
||||
.body(b"payload".to_vec())
|
||||
.send()
|
||||
.await
|
||||
.expect_err("an unresolvable host must not succeed");
|
||||
|
||||
let message = transport_reason_for(url, &error);
|
||||
assert!(
|
||||
message.contains("minio.invalid:9000"),
|
||||
"the failure has to name the storage host, got: {message}"
|
||||
);
|
||||
assert!(
|
||||
!message.contains("X-Amz-Signature"),
|
||||
"the signature must never reach the message, got: {message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
+153
-15
@@ -8,7 +8,6 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::sleep;
|
||||
|
||||
static GLOBAL_SCHEDULER: std::sync::Mutex<Option<Arc<SyncScheduler>>> = std::sync::Mutex::new(None);
|
||||
|
||||
@@ -22,6 +21,17 @@ pub fn set_global_scheduler(scheduler: Arc<SyncScheduler>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What `start` should do, given the flags.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum StartDecision {
|
||||
/// Nothing is running and nothing retired it. Spawn the loop.
|
||||
Start,
|
||||
/// A loop is already ticking on this scheduler.
|
||||
AlreadyRunning,
|
||||
/// `stop` was called on it, possibly before it ever ran.
|
||||
Retired,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ProfileStopTime {
|
||||
#[allow(dead_code)]
|
||||
@@ -31,6 +41,15 @@ struct ProfileStopTime {
|
||||
|
||||
pub struct SyncScheduler {
|
||||
running: Arc<AtomicBool>,
|
||||
/// Set by `stop()` and never cleared. A scheduler is one-shot.
|
||||
///
|
||||
/// The pipeline publishes a scheduler before it starts its loop, because work
|
||||
/// queued during the network checks in between has to land somewhere. That
|
||||
/// left a window where `stop()` cleared a `running` flag that was still
|
||||
/// false, so it did nothing, and the scheduler then started anyway and ticked
|
||||
/// forever with no way to reach it. `running` cannot express "retired before
|
||||
/// it ever ran", so this does.
|
||||
cancelled: Arc<AtomicBool>,
|
||||
pending_profiles: Arc<Mutex<HashMap<String, ProfileStopTime>>>,
|
||||
pending_proxies: Arc<Mutex<HashSet<String>>>,
|
||||
pending_groups: Arc<Mutex<HashSet<String>>>,
|
||||
@@ -52,6 +71,7 @@ impl SyncScheduler {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
running: Arc::new(AtomicBool::new(false)),
|
||||
cancelled: Arc::new(AtomicBool::new(false)),
|
||||
pending_profiles: Arc::new(Mutex::new(HashMap::new())),
|
||||
pending_proxies: Arc::new(Mutex::new(HashSet::new())),
|
||||
pending_groups: Arc::new(Mutex::new(HashSet::new())),
|
||||
@@ -68,7 +88,12 @@ impl SyncScheduler {
|
||||
self.running.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Retire this scheduler for good.
|
||||
///
|
||||
/// Order matters: mark it cancelled before clearing `running`, so a `start()`
|
||||
/// racing this call cannot slip between the two and begin ticking.
|
||||
pub fn stop(&self) {
|
||||
self.cancelled.store(true, Ordering::SeqCst);
|
||||
self.running.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
@@ -334,35 +359,93 @@ impl SyncScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
/// The decision `start` makes before it spawns anything.
|
||||
///
|
||||
/// Split out so it can be tested. `start` needs a `tauri::AppHandle`, which a
|
||||
/// unit test cannot build, and the retirement rule is the part worth pinning
|
||||
/// down. The `running` check stays a `swap` so two concurrent starts cannot
|
||||
/// both win.
|
||||
fn claim_start_slot(&self) -> StartDecision {
|
||||
if self.cancelled.load(Ordering::SeqCst) {
|
||||
return StartDecision::Retired;
|
||||
}
|
||||
if self.running.swap(true, Ordering::SeqCst) {
|
||||
return StartDecision::AlreadyRunning;
|
||||
}
|
||||
StartDecision::Start
|
||||
}
|
||||
|
||||
/// Begin ticking. Returns whether a loop was actually started, so the caller
|
||||
/// can log the truth instead of assuming.
|
||||
pub async fn start(
|
||||
self: Arc<Self>,
|
||||
app_handle: tauri::AppHandle,
|
||||
mut work_rx: mpsc::UnboundedReceiver<SyncWorkItem>,
|
||||
) {
|
||||
if self.running.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
) -> bool {
|
||||
match self.claim_start_slot() {
|
||||
StartDecision::Retired => {
|
||||
// Retired while the pipeline was still assembling it. Starting now
|
||||
// would leave a task nothing can stop, because the handle in the global
|
||||
// has already been replaced.
|
||||
log::info!("Sync scheduler was retired before it started; not starting it");
|
||||
return false;
|
||||
}
|
||||
StartDecision::AlreadyRunning => {
|
||||
log::warn!("Sync scheduler is already running; ignoring the second start");
|
||||
return false;
|
||||
}
|
||||
StartDecision::Start => {}
|
||||
}
|
||||
|
||||
let scheduler = self.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// A fresh `sleep` inside the `select!` restarts from zero on every
|
||||
// iteration, so a steady stream of work items kept resetting it and
|
||||
// `process_pending` never ran: queued profiles sat there for as long as
|
||||
// the stream lasted. An interval keeps its own schedule regardless of how
|
||||
// often the other arm fires. `Delay` rather than `Burst` so a slow
|
||||
// `process_pending` does not come back to a pile of missed ticks and run
|
||||
// itself back to back.
|
||||
let mut ticker = tokio::time::interval(Duration::from_millis(2000));
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
// The first tick of an interval resolves immediately. The old shape
|
||||
// always waited 2000 ms before its first pass, so consume it here and
|
||||
// keep that behaviour.
|
||||
ticker.tick().await;
|
||||
|
||||
// Once the senders are gone `recv()` resolves instantly and forever, so
|
||||
// the arm has to be disabled or the loop spins hot on a dead channel.
|
||||
let mut work_channel_open = true;
|
||||
|
||||
while scheduler.running.load(Ordering::SeqCst) {
|
||||
tokio::select! {
|
||||
Some(work_item) = work_rx.recv() => {
|
||||
match work_item {
|
||||
SyncWorkItem::Profile(id) => scheduler.queue_profile_sync(id).await,
|
||||
SyncWorkItem::Proxy(id) => scheduler.queue_proxy_sync(id).await,
|
||||
SyncWorkItem::Group(id) => scheduler.queue_group_sync(id).await,
|
||||
SyncWorkItem::Vpn(id) => scheduler.queue_vpn_sync(id).await,
|
||||
SyncWorkItem::Extension(id) => scheduler.queue_extension_sync(id).await,
|
||||
SyncWorkItem::ExtensionGroup(id) => scheduler.queue_extension_group_sync(id).await,
|
||||
SyncWorkItem::Tombstone(entity_type, entity_id) => {
|
||||
scheduler.queue_tombstone(entity_type, entity_id).await
|
||||
received = work_rx.recv(), if work_channel_open => {
|
||||
match received {
|
||||
Some(work_item) => match work_item {
|
||||
SyncWorkItem::Profile(id) => scheduler.queue_profile_sync(id).await,
|
||||
SyncWorkItem::Proxy(id) => scheduler.queue_proxy_sync(id).await,
|
||||
SyncWorkItem::Group(id) => scheduler.queue_group_sync(id).await,
|
||||
SyncWorkItem::Vpn(id) => scheduler.queue_vpn_sync(id).await,
|
||||
SyncWorkItem::Extension(id) => scheduler.queue_extension_sync(id).await,
|
||||
SyncWorkItem::ExtensionGroup(id) => scheduler.queue_extension_group_sync(id).await,
|
||||
SyncWorkItem::Tombstone(entity_type, entity_id) => {
|
||||
scheduler.queue_tombstone(entity_type, entity_id).await
|
||||
}
|
||||
},
|
||||
None => {
|
||||
// The subscription is gone, so no more live updates from other
|
||||
// devices. Local changes and the timer still work, so keep
|
||||
// ticking rather than ending the scheduler.
|
||||
log::warn!(
|
||||
"Sync work channel closed; continuing on the timer without live updates"
|
||||
);
|
||||
work_channel_open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = sleep(Duration::from_millis(2000)) => {
|
||||
_ = ticker.tick() => {
|
||||
scheduler.process_pending(&app_handle_clone).await;
|
||||
}
|
||||
}
|
||||
@@ -370,6 +453,8 @@ impl SyncScheduler {
|
||||
|
||||
log::info!("Sync scheduler stopped");
|
||||
});
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
async fn process_pending(&self, app_handle: &tauri::AppHandle) {
|
||||
@@ -853,3 +938,56 @@ impl SyncScheduler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_fresh_scheduler_starts_once() {
|
||||
let scheduler = SyncScheduler::new();
|
||||
assert_eq!(scheduler.claim_start_slot(), StartDecision::Start);
|
||||
assert!(scheduler.is_running());
|
||||
assert_eq!(
|
||||
scheduler.claim_start_slot(),
|
||||
StartDecision::AlreadyRunning,
|
||||
"a second start must not spawn a second loop on the same scheduler"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scheduler_retired_before_it_ran_never_starts() {
|
||||
// The pipeline publishes a scheduler, then awaits two network checks, then
|
||||
// starts the loop. A restart landing in that window calls `stop()` on a
|
||||
// scheduler that has not started yet. `running` was already false, so the
|
||||
// old `stop()` did nothing at all, the loop started afterwards, and it
|
||||
// ticked forever with the global already pointing elsewhere.
|
||||
let scheduler = SyncScheduler::new();
|
||||
assert!(!scheduler.is_running());
|
||||
|
||||
scheduler.stop();
|
||||
|
||||
assert_eq!(
|
||||
scheduler.claim_start_slot(),
|
||||
StartDecision::Retired,
|
||||
"a scheduler stopped before starting must stay stopped"
|
||||
);
|
||||
assert!(
|
||||
!scheduler.is_running(),
|
||||
"refusing to start must not leave the running flag set"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopping_a_running_scheduler_retires_it_for_good() {
|
||||
let scheduler = SyncScheduler::new();
|
||||
assert_eq!(scheduler.claim_start_slot(), StartDecision::Start);
|
||||
|
||||
scheduler.stop();
|
||||
assert!(!scheduler.is_running());
|
||||
|
||||
// A scheduler is one-shot. Restarting the pipeline builds a new one, so a
|
||||
// retired instance coming back to life could only ever be a duplicate.
|
||||
assert_eq!(scheduler.claim_start_slot(), StartDecision::Retired);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.29.5",
|
||||
"version": "0.30.0",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
+68
-33
@@ -75,6 +75,11 @@ import {
|
||||
ONBOARDING_TOUR_FINISHED_EVENT,
|
||||
setOnboardingActive,
|
||||
} from "@/lib/onboarding-signal";
|
||||
import {
|
||||
matchesProfile,
|
||||
type ProfileSearchContext,
|
||||
parseProfileSearch,
|
||||
} from "@/lib/profile-search";
|
||||
import {
|
||||
matchesGroupDigit,
|
||||
matchesShortcut,
|
||||
@@ -91,6 +96,7 @@ import {
|
||||
import type {
|
||||
BrowserProfile,
|
||||
ConsistencyResult,
|
||||
ExtensionGroup,
|
||||
PreLaunchChecks,
|
||||
SyncSettings,
|
||||
WayfernConfig,
|
||||
@@ -266,6 +272,36 @@ export default function Home() {
|
||||
|
||||
const { vpnConfigs } = useVpnEvents();
|
||||
|
||||
// Extension groups feed both the table's Ext column and the search filter's
|
||||
// `ext:` lookup, so the list is loaded here and handed down rather than
|
||||
// fetched twice. Refreshed when the backend emits 'extensions-changed'
|
||||
// (group rename/create/delete).
|
||||
const [extensionGroups, setExtensionGroups] = useState<ExtensionGroup[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
let unlisten: (() => void) | undefined;
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await invoke<ExtensionGroup[]>("list_extension_groups");
|
||||
if (mounted) setExtensionGroups(data);
|
||||
} catch (e) {
|
||||
console.error("Failed to load extension groups:", e);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
void listen("extensions-changed", () => {
|
||||
void load();
|
||||
}).then((u) => {
|
||||
if (mounted) unlisten = u;
|
||||
else u();
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Synchronizer sessions
|
||||
const { getProfileSyncInfo } = useSyncSessions();
|
||||
const [syncLeaderProfile, setSyncLeaderProfile] =
|
||||
@@ -1921,41 +1957,39 @@ export default function Home() {
|
||||
void checkSelfHostedSync();
|
||||
}, [checkSelfHostedSync]);
|
||||
|
||||
// Filter data by selected group and search query
|
||||
// A profile stores ids, and the query asks about names, so the matcher is
|
||||
// handed the resolution up front. Built off the entity lists rather than off
|
||||
// `profiles`, because the alternative — a .find() per row per term — is
|
||||
// O(profiles x entities) on every single keystroke.
|
||||
const searchContext = useMemo<ProfileSearchContext>(
|
||||
() => ({
|
||||
groupNames: new Map(groupsData.map((g) => [g.id, g.name])),
|
||||
proxyNames: new Map(storedProxies.map((p) => [p.id, p.name])),
|
||||
vpnNames: new Map(vpnConfigs.map((v) => [v.id, v.name])),
|
||||
extensionGroupNames: new Map(extensionGroups.map((e) => [e.id, e.name])),
|
||||
runningProfiles,
|
||||
}),
|
||||
[groupsData, storedProxies, vpnConfigs, extensionGroups, runningProfiles],
|
||||
);
|
||||
|
||||
// Filter data by selected group and search query. The two are independent
|
||||
// controls and both apply: the rail narrows to a group, the query narrows
|
||||
// within whatever the rail left.
|
||||
const filteredProfiles = useMemo(() => {
|
||||
let filtered = profiles;
|
||||
// "__all__" is a virtual filter that shows every profile (including
|
||||
// ungrouped ones). Any other value is a real group id; ungrouped profiles
|
||||
// only show through "All".
|
||||
const inGroup =
|
||||
!selectedGroupId || selectedGroupId === "__all__"
|
||||
? profiles
|
||||
: profiles.filter((profile) => profile.group_id === selectedGroupId);
|
||||
|
||||
// Filter by group. "__all__" is a virtual filter that shows every
|
||||
// profile (including ungrouped ones). Any other value is a real
|
||||
// group id; ungrouped profiles only show through "All".
|
||||
if (!selectedGroupId || selectedGroupId === "__all__") {
|
||||
filtered = profiles;
|
||||
} else {
|
||||
filtered = profiles.filter(
|
||||
(profile) => profile.group_id === selectedGroupId,
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
filtered = filtered.filter((profile) => {
|
||||
// Search in profile name
|
||||
if (profile.name.toLowerCase().includes(query)) return true;
|
||||
|
||||
// Search in note
|
||||
if (profile.note?.toLowerCase().includes(query)) return true;
|
||||
|
||||
// Search in tags
|
||||
if (profile.tags?.some((tag) => tag.toLowerCase().includes(query)))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [profiles, selectedGroupId, searchQuery]);
|
||||
const parsed = parseProfileSearch(searchQuery);
|
||||
if (parsed.isEmpty) return inGroup;
|
||||
return inGroup.filter((profile) =>
|
||||
matchesProfile(profile, parsed, searchContext),
|
||||
);
|
||||
}, [profiles, selectedGroupId, searchQuery, searchContext]);
|
||||
|
||||
// Update loading states
|
||||
const isLoading = profilesLoading || groupsLoading || proxiesLoading;
|
||||
@@ -2015,6 +2049,7 @@ export default function Home() {
|
||||
onCopyCookiesToProfile={handleCopyCookiesToProfile}
|
||||
onOpenCookieManagement={handleOpenCookieManagement}
|
||||
runningProfiles={runningProfiles}
|
||||
extensionGroups={extensionGroups}
|
||||
isUpdating={isUpdating}
|
||||
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
|
||||
onAssignProfilesToGroup={handleAssignProfilesToGroup}
|
||||
|
||||
@@ -5,9 +5,11 @@ import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuChevronRight, LuUpload } from "react-icons/lu";
|
||||
import { LuChevronRight } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { CookiePastePanel, IssueRow } from "@/components/cookie-paste-panel";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AnimatedDisclosureChevron,
|
||||
AnimatedDisclosureContent,
|
||||
@@ -31,19 +33,17 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import type {
|
||||
BrowserProfile,
|
||||
CookieAnalysis,
|
||||
CookiePasteImportResult,
|
||||
CookieReadResult,
|
||||
CookieWriteMode,
|
||||
DomainCookies,
|
||||
UnifiedCookie,
|
||||
} from "@/types";
|
||||
|
||||
interface CookieImportResult {
|
||||
cookies_imported: number;
|
||||
cookies_replaced: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
interface CookieManagementDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -59,21 +59,8 @@ type SelectionState = Record<
|
||||
}
|
||||
>;
|
||||
|
||||
const countCookies = (content: string): number => {
|
||||
const trimmed = content.trim();
|
||||
if (trimmed.startsWith("[")) {
|
||||
try {
|
||||
const arr = JSON.parse(trimmed);
|
||||
if (Array.isArray(arr)) return arr.length;
|
||||
} catch {
|
||||
// Fall through to Netscape counting
|
||||
}
|
||||
}
|
||||
return content.split("\n").filter((line) => {
|
||||
const l = line.trim();
|
||||
return l && !l.startsWith("#");
|
||||
}).length;
|
||||
};
|
||||
/** Long enough that a paste is not re-parsed on every keystroke of a fix. */
|
||||
const ANALYZE_DEBOUNCE_MS = 250;
|
||||
|
||||
function formatJsonCookies(cookies: UnifiedCookie[]): string {
|
||||
const arr = cookies.map((c) => {
|
||||
@@ -130,13 +117,16 @@ export function CookieManagementDialog({
|
||||
}: CookieManagementDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// Import state
|
||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [cookieCount, setCookieCount] = useState(0);
|
||||
const [pasteContent, setPasteContent] = useState("");
|
||||
const [pasteSite, setPasteSite] = useState("");
|
||||
const [writeMode, setWriteMode] = useState<CookieWriteMode>("merge");
|
||||
const [includeExpired, setIncludeExpired] = useState(false);
|
||||
const [analysis, setAnalysis] = useState<CookieAnalysis | null>(null);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [importResult, setImportResult] = useState<CookieImportResult | null>(
|
||||
null,
|
||||
);
|
||||
const [importResult, setImportResult] =
|
||||
useState<CookiePasteImportResult | null>(null);
|
||||
|
||||
// Export state
|
||||
const [format, setFormat] = useState<"netscape" | "json">("json");
|
||||
@@ -179,7 +169,7 @@ export function CookieManagementDialog({
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("cookies.management.loadFailed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
error: translateBackendError(t, err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
@@ -196,9 +186,13 @@ export function CookieManagementDialog({
|
||||
}, [activeTab, profile, exportCookieData, loadExportCookies]);
|
||||
|
||||
const resetImportState = useCallback(() => {
|
||||
setFileContent(null);
|
||||
setFileName(null);
|
||||
setCookieCount(0);
|
||||
setPasteContent("");
|
||||
setPasteSite("");
|
||||
setWriteMode("merge");
|
||||
setIncludeExpired(false);
|
||||
setAnalysis(null);
|
||||
setIsAnalyzing(false);
|
||||
setImportError(null);
|
||||
setIsImporting(false);
|
||||
setImportResult(null);
|
||||
}, []);
|
||||
@@ -229,41 +223,82 @@ export function CookieManagementDialog({
|
||||
[resetImportState, resetExportState],
|
||||
);
|
||||
|
||||
const handleFileRead = useCallback(
|
||||
(file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
setFileContent(content);
|
||||
setFileName(file.name);
|
||||
setCookieCount(countCookies(content));
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error(t("cookies.management.fileReadError"));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
const profileId = profile?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !profileId || importResult) return;
|
||||
if (pasteContent.trim() === "") {
|
||||
setAnalysis(null);
|
||||
setIsAnalyzing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Editing the paste is the user acting on the last failure, so retire it.
|
||||
setImportError(null);
|
||||
setIsAnalyzing(true);
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
void invoke<CookieAnalysis>("analyze_pasted_cookies", {
|
||||
profileId,
|
||||
content: pasteContent,
|
||||
site: pasteSite.trim() === "" ? null : pasteSite,
|
||||
})
|
||||
.then((result) => {
|
||||
if (!cancelled) setAnalysis(result);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
setAnalysis(null);
|
||||
setImportError(translateBackendError(t, error));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsAnalyzing(false);
|
||||
});
|
||||
}, ANALYZE_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [isOpen, profileId, pasteContent, pasteSite, importResult, t]);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (!fileContent || !profile) return;
|
||||
if (!profileId) return;
|
||||
setIsImporting(true);
|
||||
setImportError(null);
|
||||
try {
|
||||
const result = await invoke<CookieImportResult>(
|
||||
"import_cookies_from_file",
|
||||
const result = await invoke<CookiePasteImportResult>(
|
||||
"import_pasted_cookies",
|
||||
{
|
||||
profileId: profile.id,
|
||||
content: fileContent,
|
||||
profileId,
|
||||
content: pasteContent,
|
||||
site: pasteSite.trim() === "" ? null : pasteSite,
|
||||
mode: writeMode,
|
||||
includeExpired,
|
||||
},
|
||||
);
|
||||
setImportResult(result);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
// Kept inside the dialog rather than toasted: a toast would take the
|
||||
// failure away while leaving the user with a paste they cannot fix.
|
||||
setImportError(translateBackendError(t, error));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [fileContent, profile]);
|
||||
}, [profileId, pasteContent, pasteSite, writeMode, includeExpired, t]);
|
||||
|
||||
const importBlockedReason = useMemo(() => {
|
||||
if (pasteContent.trim() === "") return t("cookies.paste.disabledEmpty");
|
||||
if (isAnalyzing || !analysis) return null;
|
||||
if (analysis.blockedBy) {
|
||||
return translateBackendError(t, analysis.blockedBy);
|
||||
}
|
||||
if (analysis.siteRequired) return t("cookies.paste.disabledSite");
|
||||
if (analysis.cookies.length === 0) {
|
||||
return t("cookies.paste.disabledNoCookies");
|
||||
}
|
||||
return null;
|
||||
}, [pasteContent, isAnalyzing, analysis, t]);
|
||||
|
||||
const getSelectedCookies = useCallback((): UnifiedCookie[] => {
|
||||
if (!exportCookieData) return [];
|
||||
@@ -312,7 +347,7 @@ export function CookieManagementDialog({
|
||||
toast.success(t("cookies.export.success"));
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
toast.error(translateBackendError(t, error));
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
@@ -415,92 +450,102 @@ export function CookieManagementDialog({
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="import" className="mt-4 space-y-4">
|
||||
{!fileContent && (
|
||||
<div className="space-y-4">
|
||||
{!importResult && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("cookies.management.importDescription")}
|
||||
</p>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-8 transition-colors hover:border-muted-foreground/50"
|
||||
onClick={() =>
|
||||
document.getElementById("cookie-file-input")?.click()
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
document.getElementById("cookie-file-input")?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LuUpload className="mb-4 size-10 text-muted-foreground" />
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t("cookies.management.dropPrompt")}
|
||||
<br />
|
||||
<span className="text-xs">
|
||||
{t("cookies.management.fileFormats")}
|
||||
</span>
|
||||
</p>
|
||||
<input
|
||||
id="cookie-file-input"
|
||||
type="file"
|
||||
accept=".txt,.cookies,.json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileRead(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileContent && !importResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/30 p-4">
|
||||
<div>
|
||||
<div className="font-medium">{fileName}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("cookies.management.cookiesFound", {
|
||||
count: cookieCount,
|
||||
})}
|
||||
</div>
|
||||
<CookiePastePanel
|
||||
content={pasteContent}
|
||||
onContentChange={setPasteContent}
|
||||
site={pasteSite}
|
||||
onSiteChange={setPasteSite}
|
||||
mode={writeMode}
|
||||
onModeChange={setWriteMode}
|
||||
includeExpired={includeExpired}
|
||||
onIncludeExpiredChange={setIncludeExpired}
|
||||
analysis={analysis}
|
||||
isAnalyzing={isAnalyzing}
|
||||
disabled={isImporting}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* Sits with the button, not at the top of the dialog: the
|
||||
panel is taller than the viewport, so a failure announced
|
||||
above the description is a failure nobody sees. */}
|
||||
{importError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{importError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
variant={
|
||||
writeMode === "replaceMatchingSites"
|
||||
? "destructive"
|
||||
: "default"
|
||||
}
|
||||
onClick={() => void handleImport()}
|
||||
disabled={
|
||||
isAnalyzing ||
|
||||
analysis === null ||
|
||||
importBlockedReason !== null
|
||||
}
|
||||
>
|
||||
{t("common.buttons.import")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
{importBlockedReason && (
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
{importBlockedReason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<RippleButton variant="outline" onClick={resetImportState}>
|
||||
{t("cookies.management.backButton")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleImport()}
|
||||
disabled={cookieCount === 0}
|
||||
>
|
||||
{t("cookies.management.importButton")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{importResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-success/10 p-4">
|
||||
<div className="font-medium text-success-text">
|
||||
{t("cookies.management.importedSuccess", {
|
||||
imported: importResult.cookies_imported,
|
||||
replaced: importResult.cookies_replaced,
|
||||
})}
|
||||
</div>
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{t("cookies.management.linesSkipped", {
|
||||
count: importResult.errors.length,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 rounded-lg bg-muted/30 p-4 sm:grid-cols-4">
|
||||
<ResultCounter
|
||||
label={t("cookies.paste.resultAdded")}
|
||||
value={importResult.added}
|
||||
/>
|
||||
<ResultCounter
|
||||
label={t("cookies.paste.resultOverwritten")}
|
||||
value={importResult.overwritten}
|
||||
/>
|
||||
<ResultCounter
|
||||
label={t("cookies.paste.resultDeleted")}
|
||||
value={importResult.deleted}
|
||||
/>
|
||||
<ResultCounter
|
||||
label={t("cookies.paste.resultSkipped")}
|
||||
value={importResult.skipped}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{importResult.issues.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("cookies.paste.issuesTitle")}</Label>
|
||||
<FadingScrollArea className="max-h-[clamp(100px,24vh,300px)]">
|
||||
<div className="space-y-1 pr-3">
|
||||
{importResult.issues.map((issue, index) => (
|
||||
<IssueRow
|
||||
key={`${issue.code}-${issue.source ?? ""}-${index}`}
|
||||
issue={issue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</FadingScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<RippleButton onClick={handleClose}>
|
||||
{t("cookies.management.doneButton")}
|
||||
@@ -605,6 +650,16 @@ export function CookieManagementDialog({
|
||||
);
|
||||
}
|
||||
|
||||
/** Zeros are shown too: "deleted 0" is the reassurance replace mode needs. */
|
||||
function ResultCounter({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="text-lg font-medium tabular-nums">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExportDomainRowProps {
|
||||
domain: DomainCookies;
|
||||
selection: SelectionState;
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
"use client";
|
||||
|
||||
import type { TFunction } from "i18next";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type {
|
||||
CookieAnalysis,
|
||||
CookieIssue,
|
||||
CookiePasteFormat,
|
||||
CookieWriteMode,
|
||||
PastedCookiePreview,
|
||||
} from "@/types";
|
||||
|
||||
/** Issue codes `cookie_paste.rs` can emit, mapped to their translation key. */
|
||||
const ISSUE_KEYS: Record<string, string> = {
|
||||
EMPTY_INPUT: "emptyInput",
|
||||
SITE_INVALID: "siteInvalid",
|
||||
UNRECOGNIZED_FORMAT: "unrecognizedFormat",
|
||||
SITE_REQUIRED: "siteRequired",
|
||||
NO_COOKIES_FOUND: "noCookiesFound",
|
||||
NAME_EMPTY: "nameEmpty",
|
||||
NAME_INVALID: "nameInvalid",
|
||||
NAME_MISSING: "nameMissing",
|
||||
VALUE_INVALID: "valueInvalid",
|
||||
VALUE_COERCED: "valueCoerced",
|
||||
DOMAIN_FROM_SITE: "domainFromSite",
|
||||
DOMAIN_MISSING: "domainMissing",
|
||||
DOMAIN_INVALID: "domainInvalid",
|
||||
DOMAIN_ATTRIBUTE_IGNORED: "domainAttributeIgnored",
|
||||
HOST_ONLY_MISMATCH: "hostOnlyMismatch",
|
||||
PATH_REPAIRED: "pathRepaired",
|
||||
EXPIRY_MILLISECONDS: "expiryMilliseconds",
|
||||
EXPIRY_CLAMPED: "expiryClamped",
|
||||
EXPIRY_INVALID: "expiryInvalid",
|
||||
EXPIRES_INVALID: "expiresInvalid",
|
||||
MAX_AGE_INVALID: "maxAgeInvalid",
|
||||
MAX_AGE_DELETION: "maxAgeDeletion",
|
||||
SAME_SITE_NONE_INSECURE: "sameSiteNoneInsecure",
|
||||
SAME_SITE_UNRECOGNIZED: "sameSiteUnrecognized",
|
||||
DUPLICATE_COOKIE: "duplicateCookie",
|
||||
BOOL_COERCED_FROM_STRING: "boolCoercedFromString",
|
||||
BOOL_INVALID: "boolInvalid",
|
||||
QUOTED_VALUE: "quotedValue",
|
||||
JSON_PARSE_FAILED: "jsonParseFailed",
|
||||
JSON_NOT_COOKIE_LIST: "jsonNotCookieList",
|
||||
JSON_ENTRY_NOT_OBJECT: "jsonEntryNotObject",
|
||||
NETSCAPE_PATH_OMITTED: "netscapePathOmitted",
|
||||
NETSCAPE_FIELD_COUNT: "netscapeFieldCount",
|
||||
NETSCAPE_INCLUDE_SUBDOMAINS_INVALID: "netscapeIncludeSubdomainsInvalid",
|
||||
NETSCAPE_SECURE_INVALID: "netscapeSecureInvalid",
|
||||
NETSCAPE_EXPIRY_INVALID: "netscapeExpiryInvalid",
|
||||
NAME_VALUE_NO_PAIR: "nameValueNoPair",
|
||||
PAIR_TREATED_AS_ATTRIBUTE: "pairTreatedAsAttribute",
|
||||
};
|
||||
|
||||
const FORMAT_KEYS: Record<CookiePasteFormat, string> = {
|
||||
json: "cookies.paste.formatJson",
|
||||
netscape: "cookies.paste.formatNetscape",
|
||||
nameValue: "cookies.paste.formatNameValue",
|
||||
};
|
||||
|
||||
const VISIBLE_ISSUES = 5;
|
||||
|
||||
const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||
["year", 31_536_000],
|
||||
["month", 2_592_000],
|
||||
["day", 86_400],
|
||||
["hour", 3600],
|
||||
["minute", 60],
|
||||
];
|
||||
|
||||
export interface CookiePastePanelProps {
|
||||
content: string;
|
||||
onContentChange: (content: string) => void;
|
||||
site: string;
|
||||
onSiteChange: (site: string) => void;
|
||||
mode: CookieWriteMode;
|
||||
onModeChange: (mode: CookieWriteMode) => void;
|
||||
includeExpired: boolean;
|
||||
onIncludeExpiredChange: (includeExpired: boolean) => void;
|
||||
analysis: CookieAnalysis | null;
|
||||
isAnalyzing: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export function CookiePastePanel({
|
||||
content,
|
||||
onContentChange,
|
||||
site,
|
||||
onSiteChange,
|
||||
mode,
|
||||
onModeChange,
|
||||
includeExpired,
|
||||
onIncludeExpiredChange,
|
||||
analysis,
|
||||
isAnalyzing,
|
||||
disabled,
|
||||
}: CookiePastePanelProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showAllIssues, setShowAllIssues] = useState(false);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
|
||||
const relativeFormatter = useMemo(
|
||||
() => new Intl.RelativeTimeFormat(i18n.language, { numeric: "auto" }),
|
||||
[i18n.language],
|
||||
);
|
||||
|
||||
const readFile = useCallback(
|
||||
(file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
setFileError(null);
|
||||
onContentChange(String(event.target?.result ?? ""));
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setFileError(t("cookies.management.fileReadError"));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
},
|
||||
[onContentChange, t],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (!file) return;
|
||||
event.preventDefault();
|
||||
readFile(file);
|
||||
},
|
||||
[readFile],
|
||||
);
|
||||
|
||||
// A JSON or Netscape entry with no domain of its own needs the site as much
|
||||
// as a bare pair does: without the field, "carries no domain and no site was
|
||||
// given" is a dead end with no control anywhere that answers it. The last
|
||||
// clause keeps the field once anything has been typed into it, because every
|
||||
// other condition stops being true the moment the site is accepted, which
|
||||
// would yank the input out from under the cursor.
|
||||
const siteVisible =
|
||||
analysis !== null &&
|
||||
(analysis.siteRequired ||
|
||||
analysis.format === "nameValue" ||
|
||||
analysis.issues.some((issue) => issue.code === "DOMAIN_MISSING") ||
|
||||
site.trim() !== "");
|
||||
|
||||
const scopeDomains = useMemo(() => {
|
||||
if (!analysis) return [];
|
||||
return [...new Set(analysis.cookies.map((cookie) => cookie.domain))];
|
||||
}, [analysis]);
|
||||
|
||||
const issues = analysis?.issues ?? [];
|
||||
const shownIssues = showAllIssues ? issues : issues.slice(0, VISIBLE_ISSUES);
|
||||
|
||||
const formatExpiry = useCallback(
|
||||
(expires: number) => {
|
||||
if (expires === 0) return t("cookies.paste.session");
|
||||
const delta = expires - Math.floor(Date.now() / 1000);
|
||||
for (const [unit, seconds] of RELATIVE_UNITS) {
|
||||
if (Math.abs(delta) >= seconds) {
|
||||
return relativeFormatter.format(Math.trunc(delta / seconds), unit);
|
||||
}
|
||||
}
|
||||
return relativeFormatter.format(delta, "second");
|
||||
},
|
||||
[relativeFormatter, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cookie-paste-input">{t("cookies.paste.label")}</Label>
|
||||
<Textarea
|
||||
id="cookie-paste-input"
|
||||
rows={10}
|
||||
spellCheck={false}
|
||||
disabled={disabled}
|
||||
value={content}
|
||||
placeholder={t("cookies.paste.placeholder")}
|
||||
className="resize-y font-mono text-xs"
|
||||
onChange={(event) => {
|
||||
setFileError(null);
|
||||
onContentChange(event.target.value);
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(event) => {
|
||||
if (event.dataTransfer.types.includes("Files")) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="text-xs text-muted-foreground underline-offset-2 transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{t("cookies.paste.chooseFile")}
|
||||
</button>
|
||||
{isAnalyzing ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("cookies.paste.analyzing")}
|
||||
</span>
|
||||
) : (
|
||||
analysis !== null &&
|
||||
content.trim() !== "" && (
|
||||
<Badge
|
||||
variant={analysis.format ? "secondary" : "destructive"}
|
||||
className="font-normal"
|
||||
>
|
||||
{analysis.format
|
||||
? t(FORMAT_KEYS[analysis.format])
|
||||
: t("cookies.paste.formatUnknown")}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".txt,.cookies,.json"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) readFile(file);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{fileError && (
|
||||
<p className="text-xs text-destructive-text">{fileError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{siteVisible && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cookie-paste-site">
|
||||
{t("cookies.paste.siteLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="cookie-paste-site"
|
||||
disabled={disabled}
|
||||
value={site}
|
||||
placeholder={t("cookies.paste.sitePlaceholder")}
|
||||
onChange={(event) => {
|
||||
onSiteChange(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("cookies.paste.siteHelp")}
|
||||
</p>
|
||||
{scopeDomains.map((domain) => (
|
||||
<p key={domain} className="text-xs text-foreground">
|
||||
{domain.startsWith(".")
|
||||
? t("cookies.paste.scopeSubdomains", { domain })
|
||||
: t("cookies.paste.scopeHostOnly", { domain })}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("common.labels.mode")}</Label>
|
||||
<RadioGroup
|
||||
value={mode}
|
||||
disabled={disabled}
|
||||
onValueChange={(value) => {
|
||||
onModeChange(value as CookieWriteMode);
|
||||
}}
|
||||
>
|
||||
<label
|
||||
htmlFor="cookie-mode-merge"
|
||||
className="flex cursor-pointer items-start gap-2"
|
||||
>
|
||||
<RadioGroupItem
|
||||
id="cookie-mode-merge"
|
||||
value="merge"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block text-sm font-medium">
|
||||
{t("cookies.paste.modeMerge")}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("cookies.paste.modeMergeDesc")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
htmlFor="cookie-mode-replace"
|
||||
className="flex cursor-pointer items-start gap-2"
|
||||
>
|
||||
<RadioGroupItem
|
||||
id="cookie-mode-replace"
|
||||
value="replaceMatchingSites"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block text-sm font-medium">
|
||||
{t("cookies.paste.modeReplace")}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("cookies.paste.modeReplaceDesc")}
|
||||
</span>
|
||||
{analysis && (
|
||||
<span className="block text-xs text-warning-text">
|
||||
{t("cookies.paste.replaceDeleteCount", {
|
||||
n:
|
||||
analysis.replaceDeleteCount === null
|
||||
? t("cookies.paste.unknownCount")
|
||||
: String(analysis.replaceDeleteCount),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{analysis !== null && analysis.expiredCount > 0 && (
|
||||
<label
|
||||
htmlFor="cookie-include-expired"
|
||||
className="flex cursor-pointer items-start gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
id="cookie-include-expired"
|
||||
disabled={disabled}
|
||||
checked={includeExpired}
|
||||
onCheckedChange={(checked) => {
|
||||
onIncludeExpiredChange(checked === true);
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block text-sm">
|
||||
{t("cookies.paste.includeExpired")}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("cookies.paste.expiredNote", {
|
||||
n: analysis.expiredCount,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{analysis?.clearsOnClose && (
|
||||
<Alert>
|
||||
<LuTriangleAlert className="text-warning-text" />
|
||||
<AlertDescription>
|
||||
{t("cookies.paste.clearsOnCloseWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{analysis !== null && analysis.cookies.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("cookies.paste.previewTitle", { n: analysis.cookies.length })}
|
||||
</Label>
|
||||
<Table
|
||||
containerClassName="max-h-[clamp(120px,28vh,340px)] overflow-y-auto rounded-md border"
|
||||
className="text-xs"
|
||||
>
|
||||
<TableHeader className="sticky top-0 bg-background">
|
||||
<TableRow>
|
||||
<TableHead>{t("cookies.paste.colSite")}</TableHead>
|
||||
<TableHead>{t("cookies.paste.colName")}</TableHead>
|
||||
<TableHead>{t("cookies.paste.colPath")}</TableHead>
|
||||
<TableHead>{t("cookies.paste.colExpires")}</TableHead>
|
||||
<TableHead>{t("cookies.paste.colSecure")}</TableHead>
|
||||
<TableHead>{t("cookies.paste.colHttpOnly")}</TableHead>
|
||||
<TableHead>{t("cookies.paste.colSameSite")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{analysis.cookies.map((cookie) => (
|
||||
<CookiePreviewRow
|
||||
key={`${cookie.domain}|${cookie.path}|${cookie.name}`}
|
||||
cookie={cookie}
|
||||
expiresLabel={formatExpiry(cookie.expires)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{issues.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("cookies.paste.issuesTitle")}</Label>
|
||||
<div className="space-y-1">
|
||||
{shownIssues.map((issue, index) => (
|
||||
<IssueRow
|
||||
key={`${issue.code}-${issue.source ?? ""}-${index}`}
|
||||
issue={issue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{issues.length > VISIBLE_ISSUES && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => {
|
||||
setShowAllIssues((previous) => !previous);
|
||||
}}
|
||||
>
|
||||
{showAllIssues
|
||||
? t("cookies.paste.showFewer")
|
||||
: t("cookies.paste.showAll", { n: issues.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CookiePreviewRow({
|
||||
cookie,
|
||||
expiresLabel,
|
||||
}: {
|
||||
cookie: PastedCookiePreview;
|
||||
expiresLabel: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const sameSite =
|
||||
cookie.sameSite === 2
|
||||
? t("cookies.paste.sameSiteStrict")
|
||||
: cookie.sameSite === 1
|
||||
? t("cookies.paste.sameSiteLax")
|
||||
: cookie.sameSite === 0
|
||||
? t("cookies.paste.sameSiteNone")
|
||||
: t("cookies.paste.sameSiteUnspecified");
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="font-mono whitespace-nowrap">
|
||||
{cookie.domain}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono whitespace-nowrap">
|
||||
{cookie.name}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono whitespace-nowrap">
|
||||
{cookie.path}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{expiresLabel}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{cookie.isSecure ? t("cookies.paste.yes") : t("cookies.paste.no")}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{cookie.isHttpOnly ? t("cookies.paste.yes") : t("cookies.paste.no")}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{sameSite}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueRow({ issue }: { issue: CookieIssue }) {
|
||||
const { t } = useTranslation();
|
||||
const key = ISSUE_KEYS[issue.code];
|
||||
const message = key
|
||||
? t(`cookies.paste.issues.${key}`, issue.params)
|
||||
: t("cookies.paste.issues.unknown", { code: issue.code });
|
||||
|
||||
const tone =
|
||||
issue.severity === "error"
|
||||
? "text-destructive-text"
|
||||
: issue.severity === "warning"
|
||||
? "text-warning-text"
|
||||
: "text-muted-foreground";
|
||||
|
||||
return (
|
||||
<p className={`text-xs ${tone}`}>
|
||||
{issue.source && (
|
||||
<span className="text-muted-foreground">
|
||||
{formatIssueSource(t, issue.source)}
|
||||
{": "}
|
||||
</span>
|
||||
)}
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `source` arrives as `line 4` / `cookie 12`, built in Rust where there is no
|
||||
* translator. Recognise those two shapes so the prefix is localised too.
|
||||
*/
|
||||
function formatIssueSource(t: TFunction, source: string): string {
|
||||
const match = /^(line|cookie) (\d+)$/.exec(source);
|
||||
if (!match) return source;
|
||||
return match[1] === "line"
|
||||
? t("cookies.paste.sourceLine", { n: match[2] })
|
||||
: t("cookies.paste.sourceCookie", { n: match[2] });
|
||||
}
|
||||
@@ -1492,9 +1492,12 @@ export function ExtensionManagementDialog({
|
||||
className="w-full table-fixed"
|
||||
containerClassName="overflow-visible"
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableHeader className="sticky top-0 z-10 bg-background [&_tr]:border-0">
|
||||
{extTable.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
<TableRow
|
||||
key={headerGroup.id}
|
||||
className="border-0!"
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
@@ -1524,6 +1527,7 @@ export function ExtensionManagementDialog({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="border-0! hover:bg-muted"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
@@ -1613,9 +1617,12 @@ export function ExtensionManagementDialog({
|
||||
className="w-full table-fixed"
|
||||
containerClassName="overflow-visible"
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableHeader className="sticky top-0 z-10 bg-background [&_tr]:border-0">
|
||||
{groupTable.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
<TableRow
|
||||
key={headerGroup.id}
|
||||
className="border-0!"
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
@@ -1645,6 +1652,7 @@ export function ExtensionManagementDialog({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="border-0! hover:bg-muted"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
|
||||
@@ -627,9 +627,9 @@ export function GroupManagementDialog({
|
||||
className="w-full table-fixed"
|
||||
containerClassName="overflow-visible"
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableHeader className="sticky top-0 z-10 bg-background [&_tr]:border-0">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
<TableRow key={headerGroup.id} className="border-0!">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
@@ -659,6 +659,7 @@ export function GroupManagementDialog({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="border-0! hover:bg-muted"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
|
||||
@@ -4,13 +4,25 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuChevronLeft, LuChevronRight, LuSearch, LuX } from "react-icons/lu";
|
||||
import {
|
||||
LuChevronLeft,
|
||||
LuChevronRight,
|
||||
LuCircleHelp,
|
||||
LuSearch,
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
import { useWindowDecorations } from "@/hooks/use-window-decorations";
|
||||
import { getCurrentOS } from "@/lib/browser-utils";
|
||||
import {
|
||||
PROFILE_SEARCH_EXAMPLES,
|
||||
PROFILE_SEARCH_FIELDS,
|
||||
PROFILE_SEARCH_OPERATORS,
|
||||
} from "@/lib/profile-search";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { GroupWithCount } from "@/types";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
const HOLD_MS = 150;
|
||||
@@ -42,6 +54,103 @@ interface Props {
|
||||
pageTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the search box understands. The vocabulary is data owned by the parser
|
||||
* (`src/lib/profile-search.ts`), so the tokens listed here cannot drift from the
|
||||
* ones a query is actually matched against; only the prose beside them is
|
||||
* translated, because the tokens themselves have to mean the same thing to
|
||||
* everybody who is handed a query.
|
||||
*/
|
||||
const SearchSyntaxHelp = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("search.helpLabel")}
|
||||
className="grid size-6 shrink-0 place-items-center rounded-sm text-muted-foreground transition-colors duration-100 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<LuCircleHelp className="size-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-96 max-w-[calc(100vw-1.5rem)] p-0"
|
||||
>
|
||||
<div className="space-y-3 p-3 text-xs">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{t("search.helpTitle")}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{t("search.helpIntro")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{t("search.fieldsTitle")}
|
||||
</p>
|
||||
<div className="mt-1.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||
{PROFILE_SEARCH_FIELDS.map((field) => (
|
||||
<div key={field.key} className="contents">
|
||||
<code className="font-mono text-[11px] text-foreground">
|
||||
{field.key}:
|
||||
</code>
|
||||
<span className="text-muted-foreground">
|
||||
{t(field.labelKey)}
|
||||
{field.values ? (
|
||||
<span className="ml-1.5 font-mono text-[10px] text-muted-foreground/70">
|
||||
{field.values.join(" ")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{t("search.operatorsTitle")}
|
||||
</p>
|
||||
<div className="mt-1.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||
{PROFILE_SEARCH_OPERATORS.map((operator) => (
|
||||
<div key={operator.labelKey} className="contents">
|
||||
<code className="font-mono text-[11px] text-foreground">
|
||||
{operator.token}
|
||||
</code>
|
||||
<span className="text-muted-foreground">
|
||||
{t(operator.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{t("search.examplesTitle")}
|
||||
</p>
|
||||
<div className="mt-1.5 space-y-1.5">
|
||||
{PROFILE_SEARCH_EXAMPLES.map((example) => (
|
||||
<div key={example.labelKey}>
|
||||
<code className="font-mono text-[11px] text-foreground">
|
||||
{example.query}
|
||||
</code>
|
||||
<p className="text-muted-foreground">{t(example.labelKey)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const HomeHeader = ({
|
||||
onCreateProfileDialogOpen,
|
||||
searchQuery,
|
||||
@@ -354,6 +463,8 @@ const HomeHeader = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showProfileToolbar && <SearchSyntaxHelp />}
|
||||
|
||||
{showProfileToolbar && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { CopyToClipboard } from "@/components/ui/copy-to-clipboard";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -327,6 +328,13 @@ const BOT_LABEL_WIDTH = 880;
|
||||
/** Below this the bot column leaves entirely, like the other low-priority ones. */
|
||||
const BOT_COLUMN_MIN_WIDTH = 400;
|
||||
|
||||
/**
|
||||
* Above this the table has room for the profile id. Below it the name column,
|
||||
* which takes whatever the fixed columns leave over, needs those 100px more
|
||||
* than a value that is already one click away in the info dialog.
|
||||
*/
|
||||
const PROFILE_ID_MIN_WIDTH = 1152;
|
||||
|
||||
/** Bulk enrolments of this size or larger are confirmed, as run and stop are. */
|
||||
const BULK_ENROL_CONFIRM_THRESHOLD = 10;
|
||||
|
||||
@@ -432,7 +440,7 @@ function ExtCell({
|
||||
const group = groupId
|
||||
? meta.extensionGroups.find((g) => g.id === groupId)
|
||||
: undefined;
|
||||
const label = group?.name ?? meta.t("profiles.table.extDefault");
|
||||
const label = group?.name ?? meta.t("profiles.table.none");
|
||||
|
||||
const onPick = async (nextId: string | null) => {
|
||||
setIsSaving(true);
|
||||
@@ -478,7 +486,7 @@ function ExtCell({
|
||||
>
|
||||
{groupId === null && <LuCheck className="mr-2 size-3.5" />}
|
||||
<span className={groupId === null ? "" : "ml-5"}>
|
||||
{meta.t("profiles.table.extDefault")}
|
||||
{meta.t("profiles.table.none")}
|
||||
</span>
|
||||
</CommandItem>
|
||||
{meta.extensionGroups.map((g) => (
|
||||
@@ -593,6 +601,41 @@ function DnsCell({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first eight characters of the profile's UUID: the whole first group of a
|
||||
* v4, which is also the prefix `id:` searches on, so what the row shows can be
|
||||
* pasted straight back into the search box. The clipboard gets the FULL id —
|
||||
* the only thing it is for is the REST and MCP APIs, which take nothing less.
|
||||
*/
|
||||
function ProfileIdCell({
|
||||
profile,
|
||||
meta,
|
||||
}: {
|
||||
profile: BrowserProfile;
|
||||
meta: TableMeta;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-7 w-full items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex-1 truncate font-mono text-[11px] text-muted-foreground select-text">
|
||||
{profile.id.slice(0, 8)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="font-mono text-[11px]">
|
||||
{profile.id}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<CopyToClipboard
|
||||
text={profile.id}
|
||||
variant="ghost"
|
||||
className="size-6 text-muted-foreground"
|
||||
successMessage={meta.t("toasts.success.copied")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TagsCell = React.memo<{
|
||||
profile: BrowserProfile;
|
||||
isDisabled: boolean;
|
||||
@@ -1403,6 +1446,11 @@ interface ProfilesDataTableProps {
|
||||
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
|
||||
onOpenCookieManagement?: (profile: BrowserProfile) => void;
|
||||
runningProfiles: Set<string>;
|
||||
/**
|
||||
* Loaded by the page rather than here, because the search filter resolves
|
||||
* `ext:` to a group name and needs the same list. One invoke, one listener.
|
||||
*/
|
||||
extensionGroups: ExtensionGroup[];
|
||||
isUpdating: (browser: string) => boolean;
|
||||
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
|
||||
onAssignProfilesToGroup: (profileIds: string[]) => void;
|
||||
@@ -1461,6 +1509,7 @@ export function ProfilesDataTable({
|
||||
onCopyCookiesToProfile,
|
||||
onOpenCookieManagement,
|
||||
runningProfiles,
|
||||
extensionGroups,
|
||||
isUpdating,
|
||||
onAssignProfilesToGroup,
|
||||
onAssignProfilesToProxy,
|
||||
@@ -1675,35 +1724,6 @@ export function ProfilesDataTable({
|
||||
const [countries, setCountries] = React.useState<LocationItem[]>([]);
|
||||
const [countriesLoaded, setCountriesLoaded] = React.useState(false);
|
||||
|
||||
// Extension groups for the Ext column lookup. Refreshed when the
|
||||
// backend emits 'extensions-changed' (group rename/create/delete).
|
||||
const [extensionGroups, setExtensionGroups] = React.useState<
|
||||
ExtensionGroup[]
|
||||
>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
let unlisten: (() => void) | undefined;
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await invoke<ExtensionGroup[]>("list_extension_groups");
|
||||
if (mounted) setExtensionGroups(data);
|
||||
} catch (e) {
|
||||
console.error("Failed to load extension groups:", e);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
void listen("extensions-changed", () => {
|
||||
void load();
|
||||
}).then((u) => {
|
||||
if (mounted) unlisten = u;
|
||||
else u();
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
const canCreateLocationProxy = false;
|
||||
|
||||
const loadCountries = React.useCallback(async () => {
|
||||
@@ -3148,6 +3168,19 @@ export function ProfilesDataTable({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "profileId",
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
header: ({ table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
return meta.t("profiles.table.profileId");
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
return <ProfileIdCell profile={row.original} meta={meta} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
size: 100,
|
||||
@@ -3242,7 +3275,7 @@ export function ProfilesDataTable({
|
||||
? effectiveVpn.name
|
||||
: effectiveProxy
|
||||
? effectiveProxy.name
|
||||
: meta.t("profiles.table.notSelected");
|
||||
: meta.t("profiles.table.none");
|
||||
const vpnBadge = effectiveVpn ? "WG" : null;
|
||||
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
|
||||
const selectedId = effectiveVpnId ?? effectiveProxyId ?? null;
|
||||
@@ -3562,11 +3595,16 @@ export function ProfilesDataTable({
|
||||
// Low-priority columns leave the table as the container narrows (most
|
||||
// expendable first); their data stays reachable via the profile info
|
||||
// dialog. Visibility (not CSS hiding) so table-fixed reclaims the width.
|
||||
// `bot` starts hidden and is switched on by the resize effect below. An
|
||||
// unentitled account must never see a paid column, not even for the frame
|
||||
// before the observer's first measurement lands.
|
||||
// `bot` and `profileId` start hidden and are switched on by the resize effect
|
||||
// below. An unentitled account must never see a paid column, not even for the
|
||||
// frame before the observer's first measurement lands, and the id must not
|
||||
// flash into a narrow table for that same frame.
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({ created_at: false, bot: false });
|
||||
React.useState<VisibilityState>({
|
||||
created_at: false,
|
||||
bot: false,
|
||||
profileId: false,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
data: profiles,
|
||||
@@ -3630,6 +3668,8 @@ export function ProfilesDataTable({
|
||||
const next: VisibilityState = {
|
||||
// Always hidden — sort-only column.
|
||||
created_at: false,
|
||||
// First to leave: pure metadata, and the info dialog still has it.
|
||||
profileId: w >= PROFILE_ID_MIN_WIDTH,
|
||||
dns: w >= 768,
|
||||
ext: w >= 672,
|
||||
note: w >= 576,
|
||||
|
||||
@@ -1263,9 +1263,12 @@ export function ProxyManagementDialog({
|
||||
className="w-full table-fixed"
|
||||
containerClassName="overflow-visible"
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableHeader className="sticky top-0 z-10 bg-background [&_tr]:border-0">
|
||||
{proxiesTable.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
<TableRow
|
||||
key={headerGroup.id}
|
||||
className="border-0!"
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
@@ -1306,6 +1309,7 @@ export function ProxyManagementDialog({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="border-0! hover:bg-muted"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
@@ -1370,9 +1374,12 @@ export function ProxyManagementDialog({
|
||||
className="w-full table-fixed"
|
||||
containerClassName="overflow-visible"
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
<TableHeader className="sticky top-0 z-10 bg-background [&_tr]:border-0">
|
||||
{vpnsTable.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
<TableRow
|
||||
key={headerGroup.id}
|
||||
className="border-0!"
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
@@ -1413,6 +1420,7 @@ export function ProxyManagementDialog({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="border-0! hover:bg-muted"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
} from "@/lib/themes";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SetDefaultBrowserOutcome } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface AppSettings {
|
||||
@@ -401,14 +402,39 @@ export function SettingsDialog({
|
||||
const handleSetDefaultBrowser = useCallback(async () => {
|
||||
setIsSettingDefault(true);
|
||||
try {
|
||||
await invoke("set_as_default_browser");
|
||||
// Windows keeps the final choice for its own settings page, so a call
|
||||
// that succeeded does not always mean Donut is the default yet. Say which
|
||||
// of the two happened. Saying nothing at all is what left the user
|
||||
// watching the badge stay "Inactive" with no explanation.
|
||||
const outcome = await invoke<SetDefaultBrowserOutcome>(
|
||||
"set_as_default_browser",
|
||||
);
|
||||
await checkDefaultBrowserStatus();
|
||||
|
||||
if (outcome.status === "awaitingSystemSettings") {
|
||||
showSuccessToast(t("settings.defaultBrowser.finishInSystemSettings"), {
|
||||
description: t(
|
||||
"settings.defaultBrowser.finishInSystemSettingsDescription",
|
||||
),
|
||||
duration: 8000,
|
||||
});
|
||||
} else {
|
||||
showSuccessToast(t("settings.defaultBrowser.setSuccess"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to set as default browser:", error);
|
||||
showErrorToast(t("settings.defaultBrowser.setFailed"), {
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: t("common.errors.unknown"),
|
||||
duration: 8000,
|
||||
});
|
||||
} finally {
|
||||
setIsSettingDefault(false);
|
||||
}
|
||||
}, [checkDefaultBrowserStatus]);
|
||||
}, [checkDefaultBrowserStatus, t]);
|
||||
|
||||
const handleClearTraffic = useCallback(async () => {
|
||||
setIsClearingTraffic(true);
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { SyncSettings } from "@/types";
|
||||
import type { SyncServerCheck, SyncSettings } from "@/types";
|
||||
|
||||
const DEVICE_LINK_URL = "https://donutbrowser.com/auth/link";
|
||||
|
||||
@@ -78,49 +78,51 @@ export function SyncConfigDialog({
|
||||
const [, setLiveProxyUsage] = useState<ProxyUsage | null>(null);
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = useState<
|
||||
"unknown" | "testing" | "connected" | "error"
|
||||
"unknown" | "testing" | "connected" | "error" | "storage-unreachable"
|
||||
>("unknown");
|
||||
const [storageEndpoint, setStorageEndpoint] = useState<string | null>(null);
|
||||
const hasConfig = Boolean(serverUrl && token);
|
||||
|
||||
// `/health` is a bare liveness probe: it answers ok on a server whose storage
|
||||
// is unreachable or misconfigured, which is how a green "connected" could sit
|
||||
// next to a sync where every single file failed. `/readyz` checks storage and
|
||||
// reports the endpoint clients are handed in presigned URLs, so surface that
|
||||
// too — when transfers fail, it is the value worth checking first.
|
||||
const probeServer = useCallback(async (url: string) => {
|
||||
const base = url.replace(/\/$/, "");
|
||||
const response = await fetch(`${base}/readyz`);
|
||||
// Probing the sync server alone is what let a broken setup look correct.
|
||||
// Files never travel through that server: the client is handed a presigned
|
||||
// URL and uploads straight to storage, so a server whose storage address is
|
||||
// reachable only from its own network answers every probe while every single
|
||||
// transfer fails at connect. The check runs in the backend because that is
|
||||
// the client that performs the transfers — same DNS, proxy and TLS trust, so
|
||||
// a setup that passes here can actually move bytes.
|
||||
const probeServer = useCallback(
|
||||
(url: string) =>
|
||||
invoke<SyncServerCheck>("check_sync_server_connection", {
|
||||
serverUrl: url,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// A server old enough to predate /readyz is still a working server, so
|
||||
// fall back rather than reporting a healthy setup as broken.
|
||||
if (response.status === 404) {
|
||||
const health = await fetch(`${base}/health`);
|
||||
return { ok: health.ok, storageEndpoint: undefined };
|
||||
const applyProbeResult = useCallback((result: SyncServerCheck) => {
|
||||
setStorageEndpoint(result.storage_endpoint ?? null);
|
||||
if (!result.server_reachable || result.storage_ready === false) {
|
||||
setConnectionStatus("error");
|
||||
return "error" as const;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { ok: false as const, storageEndpoint: undefined };
|
||||
if (result.storage_reachable === false) {
|
||||
setConnectionStatus("storage-unreachable");
|
||||
return "storage-unreachable" as const;
|
||||
}
|
||||
const body = (await response.json()) as {
|
||||
storageEndpoint?: string;
|
||||
} | null;
|
||||
return { ok: true as const, storageEndpoint: body?.storageEndpoint };
|
||||
setConnectionStatus("connected");
|
||||
return "connected" as const;
|
||||
}, []);
|
||||
|
||||
const testConnection = useCallback(
|
||||
async (url: string) => {
|
||||
setConnectionStatus("testing");
|
||||
try {
|
||||
const result = await probeServer(url);
|
||||
setStorageEndpoint(result.storageEndpoint ?? null);
|
||||
setConnectionStatus(result.ok ? "connected" : "error");
|
||||
applyProbeResult(await probeServer(url));
|
||||
} catch {
|
||||
setStorageEndpoint(null);
|
||||
setConnectionStatus("error");
|
||||
}
|
||||
},
|
||||
[probeServer],
|
||||
[probeServer, applyProbeResult],
|
||||
);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
@@ -173,12 +175,18 @@ export function SyncConfigDialog({
|
||||
setConnectionStatus("testing");
|
||||
try {
|
||||
const result = await probeServer(serverUrl);
|
||||
setStorageEndpoint(result.storageEndpoint ?? null);
|
||||
if (result.ok) {
|
||||
setConnectionStatus("connected");
|
||||
const outcome = applyProbeResult(result);
|
||||
if (outcome === "connected") {
|
||||
showSuccessToast(t("sync.config.connectionSuccess"));
|
||||
} else if (outcome === "storage-unreachable") {
|
||||
// Deliberately an error, not a warning. Nothing will sync in this
|
||||
// state, and reporting it as success is the bug being fixed.
|
||||
showErrorToast(
|
||||
t("sync.config.storageUnreachable", {
|
||||
endpoint: result.storage_endpoint ?? "",
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setConnectionStatus("error");
|
||||
showErrorToast(t("sync.config.serverError"));
|
||||
}
|
||||
} catch {
|
||||
@@ -188,7 +196,7 @@ export function SyncConfigDialog({
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
}, [serverUrl, t, probeServer]);
|
||||
}, [serverUrl, t, probeServer, applyProbeResult]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
@@ -485,6 +493,19 @@ export function SyncConfigDialog({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{connectionStatus === "storage-unreachable" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="size-2 rounded-full bg-destructive" />
|
||||
{t("sync.config.storageUnreachableStatus")}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground break-all">
|
||||
{t("sync.config.storageUnreachable", {
|
||||
endpoint: storageEndpoint ?? "",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{connectionStatus === "error" && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="size-2 rounded-full bg-destructive" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import type { Key, ReactNode } from "react";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -12,6 +12,23 @@ interface StepTransitionProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slides a step into place when it changes.
|
||||
*
|
||||
* Nothing here decides whether the step is on screen. It used to: an
|
||||
* `AnimatePresence mode="wait"` held the incoming panel unmounted until the
|
||||
* outgoing one finished its exit, and both panels faded from `opacity: 0`. Both
|
||||
* halves put the content behind an animation, and an animation is not a
|
||||
* guarantee — `requestAnimationFrame` stalls whenever the webview is occluded,
|
||||
* unfocused or throttled. When it stalled the dialog was left showing the old
|
||||
* step forever, or a panel frozen at zero opacity, with the new step absent
|
||||
* from the DOM entirely.
|
||||
*
|
||||
* So the step renders immediately and at full opacity, and the only animated
|
||||
* property is a few pixels of travel. If the animation never runs, the content
|
||||
* is still there, still readable, a hair off its final position. Motion may
|
||||
* decorate a transition; it may never be what performs one.
|
||||
*/
|
||||
export function StepTransition({
|
||||
transitionKey,
|
||||
direction,
|
||||
@@ -21,39 +38,16 @@ export function StepTransition({
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false} mode="wait" custom={direction}>
|
||||
<motion.div
|
||||
key={transitionKey}
|
||||
custom={direction}
|
||||
variants={{
|
||||
enter: (customDirection: 1 | -1) => ({
|
||||
opacity: 0,
|
||||
x: reduceMotion ? 0 : customDirection * 6,
|
||||
}),
|
||||
center: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
duration: reduceMotion ? 0.16 : 0.18,
|
||||
ease: MOTION_EASE_OUT,
|
||||
},
|
||||
},
|
||||
exit: (customDirection: 1 | -1) => ({
|
||||
opacity: 0,
|
||||
x: reduceMotion ? 0 : customDirection * -6,
|
||||
transition: {
|
||||
duration: reduceMotion ? 0.16 : 0.12,
|
||||
ease: MOTION_EASE_OUT,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
className={cn(className)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<motion.div
|
||||
// Remounting on the key is what replaces the old step. It is synchronous,
|
||||
// so the swap does not depend on any animation finishing.
|
||||
key={transitionKey}
|
||||
initial={reduceMotion ? false : { x: direction * 6 }}
|
||||
animate={{ x: 0 }}
|
||||
transition={{ duration: 0.18, ease: MOTION_EASE_OUT }}
|
||||
className={cn(className)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,18 @@ import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import { RippleButton } from "@/components/ui/ripple";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -41,6 +50,14 @@ const isFingerprintEditingDisabled = (config: WayfernConfig): boolean => {
|
||||
return config.randomize_fingerprint_on_launch === true;
|
||||
};
|
||||
|
||||
/** What `generate_sample_fingerprint` returns. Identity fields are null on a
|
||||
* browser version that predates the Wayfern identity API. */
|
||||
interface GeneratedFingerprint {
|
||||
fingerprint: string;
|
||||
identity_id: string | null;
|
||||
identity_baseline: string | null;
|
||||
}
|
||||
|
||||
const getCurrentOS = (): WayfernOS => {
|
||||
if (typeof navigator === "undefined") return "linux";
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
@@ -77,18 +94,30 @@ export function WayfernConfigForm({
|
||||
useState<WayfernFingerprintConfig>({});
|
||||
const [currentOS] = useState<WayfernOS>(getCurrentOS);
|
||||
const [isGeneratingFingerprint, setIsGeneratingFingerprint] = useState(false);
|
||||
const [isRegenerateConfirmOpen, setIsRegenerateConfirmOpen] = useState(false);
|
||||
|
||||
const handleGenerateFingerprint = async () => {
|
||||
if (!profileVersion) return;
|
||||
setIsGeneratingFingerprint(true);
|
||||
try {
|
||||
const configJson = JSON.stringify(config);
|
||||
const result = await invoke<string>("generate_sample_fingerprint", {
|
||||
browser: profileBrowser ?? "wayfern",
|
||||
version: profileVersion,
|
||||
configJson,
|
||||
});
|
||||
onConfigChange("fingerprint", result);
|
||||
const result = await invoke<GeneratedFingerprint>(
|
||||
"generate_sample_fingerprint",
|
||||
{
|
||||
browser: profileBrowser ?? "wayfern",
|
||||
version: profileVersion,
|
||||
configJson,
|
||||
},
|
||||
);
|
||||
onConfigChange("fingerprint", result.fingerprint);
|
||||
// The identity travels with the fingerprint it produced. Storing one
|
||||
// without the other leaves a device the launch path cannot reproduce, so
|
||||
// it would be discarded and re-minted on the next launch.
|
||||
onConfigChange("identity_id", result.identity_id ?? undefined);
|
||||
onConfigChange(
|
||||
"identity_baseline",
|
||||
result.identity_baseline ?? undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to generate fingerprint:", error);
|
||||
} finally {
|
||||
@@ -96,6 +125,24 @@ export function WayfernConfigForm({
|
||||
}
|
||||
};
|
||||
|
||||
/** Regenerating replaces a device the profile may already be known by. Sites
|
||||
* that fingerprinted it then see a different machine behind the same cookies,
|
||||
* which is the shape that gets an account challenged or locked out, so it
|
||||
* takes a confirmation. Creating a profile has no such history to lose and
|
||||
* asks nothing. */
|
||||
const handleRegenerateClick = () => {
|
||||
if (isCreating) {
|
||||
void handleGenerateFingerprint();
|
||||
return;
|
||||
}
|
||||
setIsRegenerateConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmRegenerate = () => {
|
||||
setIsRegenerateConfirmOpen(false);
|
||||
void handleGenerateFingerprint();
|
||||
};
|
||||
|
||||
const selectedOS = config.os || currentOS;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -171,6 +218,12 @@ export function WayfernConfigForm({
|
||||
|
||||
const isEditingDisabled = isFingerprintEditingDisabled(config) || readOnly;
|
||||
|
||||
/** For an identity-backed profile these fields have no stored value to show,
|
||||
* and editing them by hand produces an inconsistent device. Hidden rather
|
||||
* than rendered blank-and-disabled, because a blank box reads as data loss
|
||||
* whereas their absence is the truth. */
|
||||
const isIdentityDerived = config.identity_id != null;
|
||||
|
||||
const renderAdvancedForm = () => (
|
||||
<div className="space-y-6">
|
||||
{/* Operating System Selection */}
|
||||
@@ -180,14 +233,14 @@ export function WayfernConfigForm({
|
||||
{profileVersion && (!isCreating || crossOsUnlocked) && (
|
||||
<LoadingButton
|
||||
isLoading={isGeneratingFingerprint}
|
||||
onClick={handleGenerateFingerprint}
|
||||
onClick={handleRegenerateClick}
|
||||
disabled={readOnly}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{isCreating
|
||||
? t("fingerprint.generateFingerprint")
|
||||
: t("fingerprint.refreshFingerprint")}
|
||||
: t("fingerprint.regenerateFingerprint")}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
@@ -894,21 +947,23 @@ export function WayfernConfigForm({
|
||||
</div>
|
||||
|
||||
{/* WebGL Parameters (JSON) */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.webglParametersJson")}</Label>
|
||||
<Textarea
|
||||
value={fingerprintConfig.webglParameters ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"webglParameters",
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder='{"7936": "Intel", "7937": "Intel(R) HD Graphics"}'
|
||||
className="font-mono text-sm"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
{!isIdentityDerived && (
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.webglParametersJson")}</Label>
|
||||
<Textarea
|
||||
value={fingerprintConfig.webglParameters ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"webglParameters",
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder='{"7936": "Intel", "7937": "Intel(R) HD Graphics"}'
|
||||
className="font-mono text-sm"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canvas Noise Seed */}
|
||||
<div className="space-y-3">
|
||||
@@ -1040,37 +1095,49 @@ export function WayfernConfigForm({
|
||||
{/* Vendor Info */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.vendorInfo")}</Label>
|
||||
<div className="grid grid-cols-1 gap-4 @md:grid-cols-2 @2xl:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vendor">{t("fingerprint.vendor")}</Label>
|
||||
<Input
|
||||
id="vendor"
|
||||
value={fingerprintConfig.vendor ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"vendor",
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder={t("common.placeholders.example", {
|
||||
value: "Google Inc.",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vendor-sub">{t("fingerprint.vendorSub")}</Label>
|
||||
<Input
|
||||
id="vendor-sub"
|
||||
value={fingerprintConfig.vendorSub ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"vendorSub",
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
isIdentityDerived
|
||||
? "grid grid-cols-1 gap-4"
|
||||
: "grid grid-cols-1 gap-4 @md:grid-cols-2 @2xl:grid-cols-3"
|
||||
}
|
||||
>
|
||||
{!isIdentityDerived && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vendor">{t("fingerprint.vendor")}</Label>
|
||||
<Input
|
||||
id="vendor"
|
||||
value={fingerprintConfig.vendor ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"vendor",
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder={t("common.placeholders.example", {
|
||||
value: "Google Inc.",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vendor-sub">
|
||||
{t("fingerprint.vendorSub")}
|
||||
</Label>
|
||||
<Input
|
||||
id="vendor-sub"
|
||||
value={fingerprintConfig.vendorSub ?? ""}
|
||||
onChange={(e) => {
|
||||
updateFingerprintConfig(
|
||||
"vendorSub",
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder=""
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="product-sub">
|
||||
{t("fingerprint.productSub")}
|
||||
@@ -1115,6 +1182,37 @@ export function WayfernConfigForm({
|
||||
|
||||
return (
|
||||
<div className={`@container space-y-6 ${className}`}>
|
||||
{/* Rendered outside the tabs so the confirmation survives whichever
|
||||
panel the button was pressed from. */}
|
||||
<Dialog
|
||||
open={isRegenerateConfirmOpen}
|
||||
onOpenChange={setIsRegenerateConfirmOpen}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("fingerprint.regenerateConfirmTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("fingerprint.regenerateConfirmDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsRegenerateConfirmOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
variant="destructive"
|
||||
onClick={handleConfirmRegenerate}
|
||||
>
|
||||
{t("fingerprint.regenerateFingerprint")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{forceAdvanced ? (
|
||||
renderAdvancedForm()
|
||||
) : (
|
||||
|
||||
@@ -36,10 +36,23 @@ const panelSpring = {
|
||||
damping: 28,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Steps travel, they do not fade in.
|
||||
*
|
||||
* Onboarding is the first thing a new install shows and the only way past it is
|
||||
* the button on the current step, so a step that fails to appear is a dead app.
|
||||
* These panels used to start at `opacity: 0` inside an `AnimatePresence
|
||||
* mode="wait"`, which put both the visibility AND the mount of every step
|
||||
* behind an animation. `requestAnimationFrame` stops whenever the webview is
|
||||
* occluded, unfocused or throttled, and when it stopped mid-transition the
|
||||
* dialog sat empty with the next step never mounted.
|
||||
*
|
||||
* Full opacity at rest means a stalled animation costs 12px of offset instead
|
||||
* of the whole screen.
|
||||
*/
|
||||
const panelVariants = {
|
||||
enter: { opacity: 0, y: 12 },
|
||||
center: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -12 },
|
||||
enter: { y: 12 },
|
||||
center: { y: 0 },
|
||||
};
|
||||
|
||||
// Concrete feature list shown on the intro step, rendered as an icon grid.
|
||||
@@ -216,14 +229,13 @@ export function WelcomeDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{step === "intro" && (
|
||||
<motion.div
|
||||
key="intro"
|
||||
variants={panelVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={panelTransition}
|
||||
className="flex flex-col gap-7"
|
||||
>
|
||||
@@ -301,7 +313,6 @@ export function WelcomeDialog({
|
||||
variants={panelVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={panelTransition}
|
||||
className="flex flex-col gap-7"
|
||||
>
|
||||
@@ -380,7 +391,6 @@ export function WelcomeDialog({
|
||||
variants={panelVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={panelTransition}
|
||||
className="flex flex-col gap-7"
|
||||
>
|
||||
@@ -438,7 +448,6 @@ export function WelcomeDialog({
|
||||
variants={panelVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={panelTransition}
|
||||
className="flex flex-col items-center gap-6 text-center"
|
||||
>
|
||||
|
||||
+170
-86
@@ -1,8 +1,64 @@
|
||||
[
|
||||
{
|
||||
"name": "@babel/code-frame",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@babel/compat-data",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@babel/core",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@babel/generator",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@babel/helper-compilation-targets",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@babel/helper-globals",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@babel/helper-string-parser",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@babel/helper-validator-identifier",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@babel/helper-validator-option",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@babel/helpers",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@babel/parser",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@babel/runtime",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@babel/template",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@babel/traverse",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@babel/types",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@floating-ui/core",
|
||||
"license": "MIT"
|
||||
@@ -23,6 +79,22 @@
|
||||
"name": "@img/colour",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@jridgewell/gen-mapping",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@jridgewell/resolve-uri",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@jridgewell/sourcemap-codec",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@jridgewell/trace-mapping",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@next/env",
|
||||
"license": "MIT"
|
||||
@@ -363,10 +435,18 @@
|
||||
"name": "@types/d3-timer",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@types/gensync",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@types/js-cookie",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@types/jsesc",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "@types/node",
|
||||
"license": "MIT"
|
||||
@@ -399,10 +479,6 @@
|
||||
"name": "aes-gcm",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "ahash",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "aho-corasick",
|
||||
"license": "Unlicense OR MIT"
|
||||
@@ -467,10 +543,6 @@
|
||||
"name": "aria-hidden",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "arrayref",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
{
|
||||
"name": "arrayvec",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
@@ -587,10 +659,6 @@
|
||||
"name": "bitstream-io",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "bitvec",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "blake2",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
@@ -619,14 +687,6 @@
|
||||
"name": "boringtun",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
{
|
||||
"name": "borsh",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "borsh-derive",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "brotli",
|
||||
"license": "BSD-3-Clause AND MIT"
|
||||
@@ -635,6 +695,10 @@
|
||||
"name": "brotli-decompressor",
|
||||
"license": "BSD-3-Clause OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "browserslist",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "bs58",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
@@ -647,26 +711,18 @@
|
||||
"name": "bumpalo",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "byte-unit",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "byte_string",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "bytecheck",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "bytecheck_derive",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "bytemuck",
|
||||
"license": "Zlib OR Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "bytemuck_derive",
|
||||
"license": "Zlib OR Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "byteorder",
|
||||
"license": "Unlicense OR MIT"
|
||||
@@ -831,6 +887,10 @@
|
||||
"name": "constant_time_eq",
|
||||
"license": "CC0-1.0 OR MIT-0 OR Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "convert-source-map",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "cookie",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
@@ -1131,10 +1191,18 @@
|
||||
"name": "either",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "electron-to-chromium",
|
||||
"license": "ISC"
|
||||
},
|
||||
{
|
||||
"name": "embed_plist",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "empathic",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "encoding_rs",
|
||||
"license": "(Apache-2.0 OR MIT) AND BSD-3-Clause"
|
||||
@@ -1187,6 +1255,10 @@
|
||||
"name": "es-toolkit",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "escalade",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "event-listener",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
@@ -1275,10 +1347,6 @@
|
||||
"name": "framer-motion",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "funty",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "futures",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
@@ -1351,6 +1419,10 @@
|
||||
"name": "generic-array",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "gensync",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "get-nonce",
|
||||
"license": "MIT"
|
||||
@@ -1575,6 +1647,10 @@
|
||||
"name": "immer",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "import-meta-resolve",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "indexmap",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
@@ -1647,14 +1723,38 @@
|
||||
"name": "jiff",
|
||||
"license": "Unlicense OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "jiff-core",
|
||||
"license": "Unlicense OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "jiff-tzdb",
|
||||
"license": "Unlicense OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "jiff-tzdb-platform",
|
||||
"license": "Unlicense OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "js-cookie",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "js-tokens",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "jsesc",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "json-patch",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "json5",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "jsonptr",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
@@ -1695,6 +1795,10 @@
|
||||
"name": "libloading",
|
||||
"license": "ISC"
|
||||
},
|
||||
{
|
||||
"name": "libm",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "libsqlite3-sys",
|
||||
"license": "MIT"
|
||||
@@ -1723,6 +1827,10 @@
|
||||
"name": "loop9",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "lru-cache",
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
{
|
||||
"name": "lucide-react",
|
||||
"license": "ISC"
|
||||
@@ -1843,6 +1951,10 @@
|
||||
"name": "no_std_io2",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "node-releases",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "nom",
|
||||
"license": "MIT"
|
||||
@@ -1963,6 +2075,10 @@
|
||||
"name": "objc2-web-kit",
|
||||
"license": "Zlib OR Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "obug",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "onborda",
|
||||
"license": "MIT"
|
||||
@@ -2164,11 +2280,11 @@
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "ptr_meta",
|
||||
"name": "pulp",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "ptr_meta_derive",
|
||||
"name": "pulp-wasm-simd-flag",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
@@ -2191,10 +2307,6 @@
|
||||
"name": "quote",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "radium",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "radix-ui",
|
||||
"license": "MIT"
|
||||
@@ -2219,6 +2331,10 @@
|
||||
"name": "ravif",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
{
|
||||
"name": "raw-cpuid",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "raw-window-handle",
|
||||
"license": "MIT OR Apache-2.0 OR Zlib"
|
||||
@@ -2271,6 +2387,10 @@
|
||||
"name": "react-style-singleton",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "reborrow",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "recharts",
|
||||
"license": "MIT"
|
||||
@@ -2307,10 +2427,6 @@
|
||||
"name": "regex-syntax",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "rend",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "reqwest",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
@@ -2339,14 +2455,6 @@
|
||||
"name": "ring-compat",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "rkyv",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "rkyv_derive",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "rusqlite",
|
||||
"license": "MIT"
|
||||
@@ -2355,10 +2463,6 @@
|
||||
"name": "rust-ini",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "rust_decimal",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "rustc-hash",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
@@ -2411,10 +2515,6 @@
|
||||
"name": "screenfull",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "seahash",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "sealed",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
@@ -2559,10 +2659,6 @@
|
||||
"name": "simd_helpers",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "simdutf8",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "siphasher",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
@@ -2671,10 +2767,6 @@
|
||||
"name": "tao",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
{
|
||||
"name": "tap",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "tar",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
@@ -2963,6 +3055,10 @@
|
||||
"name": "untrusted",
|
||||
"license": "ISC"
|
||||
},
|
||||
{
|
||||
"name": "update-browserslist-db",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "url",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
@@ -2987,10 +3083,6 @@
|
||||
"name": "use-sync-external-store",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "utf8-width",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "utf8_iter",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
@@ -3019,18 +3111,10 @@
|
||||
"name": "v_frame",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
{
|
||||
"name": "value-bag",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "victory-vendor",
|
||||
"license": "MIT AND ISC"
|
||||
},
|
||||
{
|
||||
"name": "void-elements",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "walkdir",
|
||||
"license": "MIT OR Unlicense"
|
||||
@@ -3203,10 +3287,6 @@
|
||||
"name": "wry",
|
||||
"license": "Apache-2.0 OR MIT"
|
||||
},
|
||||
{
|
||||
"name": "wyz",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "x11",
|
||||
"license": "MIT"
|
||||
@@ -3259,6 +3339,10 @@
|
||||
"name": "zbus_names",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "zcheapstr",
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "zerocopy",
|
||||
"license": "BSD-2-Clause OR Apache-2.0 OR MIT"
|
||||
|
||||
+162
-15
@@ -134,7 +134,11 @@
|
||||
"title": "Default Browser",
|
||||
"setAsDefault": "Set as Default Browser",
|
||||
"alreadyDefault": "Already Default Browser",
|
||||
"description": "When set as default, Donut Browser will handle web links and allow you to choose which profile to use."
|
||||
"description": "When set as default, Donut Browser will handle web links and allow you to choose which profile to use.",
|
||||
"setSuccess": "Donut Browser is now your default browser",
|
||||
"setFailed": "Could not set the default browser",
|
||||
"finishInSystemSettings": "Finish in Windows Settings",
|
||||
"finishInSystemSettingsDescription": "Donut Browser is registered. Windows Settings is open: choose Donut Browser under Web browser to finish."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "System Permissions",
|
||||
@@ -228,6 +232,49 @@
|
||||
"scrollGroupsLeft": "Scroll groups left",
|
||||
"scrollGroupsRight": "Scroll groups right"
|
||||
},
|
||||
"search": {
|
||||
"helpLabel": "Search syntax",
|
||||
"helpTitle": "Search syntax",
|
||||
"helpIntro": "Type words to search names, notes, tags and ids. Add fields to narrow it down.",
|
||||
"fieldsTitle": "Fields",
|
||||
"operatorsTitle": "Operators",
|
||||
"examplesTitle": "Examples",
|
||||
"fields": {
|
||||
"name": "Profile name",
|
||||
"tag": "Tag",
|
||||
"note": "Note",
|
||||
"id": "Profile id, matched from the start",
|
||||
"group": "Group name",
|
||||
"proxy": "Proxy name",
|
||||
"vpn": "VPN name",
|
||||
"ext": "Extension group name",
|
||||
"dns": "DNS blocklist",
|
||||
"os": "Operating system",
|
||||
"browser": "Browser",
|
||||
"status": "Running or not",
|
||||
"sync": "Sync mode",
|
||||
"email": "Owner email",
|
||||
"version": "Browser version",
|
||||
"locked": "Password protected",
|
||||
"ephemeral": "Ephemeral profile",
|
||||
"created": "Creation date",
|
||||
"launched": "Last launch date"
|
||||
},
|
||||
"operators": {
|
||||
"negate": "Excludes what matches",
|
||||
"quote": "Holds a value with spaces together",
|
||||
"or": "Matches either term",
|
||||
"comma": "Shorthand for either value",
|
||||
"exact": "Matches the whole value, not a part of it",
|
||||
"none": "Nothing set here; use any for the opposite",
|
||||
"compare": "Compares dates and versions; 7d, 3w and 6m count back from now"
|
||||
},
|
||||
"examples": {
|
||||
"a": "Running profiles in one group",
|
||||
"b": "Untagged profiles that have a proxy",
|
||||
"c": "Not launched for over 30 days, ignoring archived ones"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"title": "Profiles",
|
||||
"empty": "No profiles yet",
|
||||
@@ -237,6 +284,7 @@
|
||||
"noResultsDescription": "No profiles match your search criteria.",
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"none": "None",
|
||||
"browser": "Browser",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
@@ -245,10 +293,8 @@
|
||||
"proxy": "Proxy / VPN",
|
||||
"lastLaunch": "Last Launch",
|
||||
"empty": "No profiles found.",
|
||||
"notSelected": "Not Selected",
|
||||
"ext": "EXT",
|
||||
"dns": "DNS",
|
||||
"extDefault": "Default",
|
||||
"dnsLevel": "DNS blocklist: {{level}}",
|
||||
"extSearch": "Search groups…",
|
||||
"extEmpty": "No extension groups",
|
||||
@@ -262,7 +308,8 @@
|
||||
"emptyImport": "Import profiles",
|
||||
"emptyFilteredTitle": "No profiles found",
|
||||
"emptyFilteredHint": "No profiles match this group or search. Try another filter or create a new one.",
|
||||
"bot": "Bot"
|
||||
"bot": "Bot",
|
||||
"profileId": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Launch",
|
||||
@@ -636,6 +683,8 @@
|
||||
"serverError": "Server responded with an error",
|
||||
"connectFailed": "Failed to connect to server",
|
||||
"storageEndpoint": "Storage: {{endpoint}}",
|
||||
"storageUnreachableStatus": "Storage unreachable",
|
||||
"storageUnreachable": "The server is reachable, but its storage address {{endpoint}} cannot be reached from this device. File transfers will fail. If you self-host, set S3_PUBLIC_ENDPOINT to an address this device can reach.",
|
||||
"settingsSaved": "Sync settings saved",
|
||||
"saveFailed": "Failed to save settings",
|
||||
"disconnected": "Sync disconnected",
|
||||
@@ -850,12 +899,7 @@
|
||||
"menuItem": "Cookie Management",
|
||||
"tabImport": "Import",
|
||||
"tabExport": "Export",
|
||||
"importDescription": "Import cookies from a Netscape or JSON format file.",
|
||||
"dropPrompt": "Click to choose a cookie file",
|
||||
"fileFormats": "(.txt, .cookies, or .json)",
|
||||
"cookiesFound": "{{count}} cookies found",
|
||||
"importedSuccess": "Successfully imported {{imported}} cookies ({{replaced}} replaced)",
|
||||
"linesSkipped": "{{count}} line(s) skipped",
|
||||
"importDescription": "Paste cookies copied from another browser or tool, or choose a file.",
|
||||
"fileReadError": "Failed to read file",
|
||||
"loadFailed": "Failed to load cookies: {{error}}",
|
||||
"cookiesLabel": "Cookies",
|
||||
@@ -864,9 +908,7 @@
|
||||
"deselectAll": "Deselect all",
|
||||
"noCookies": "No cookies found in this profile",
|
||||
"doneButton": "Done",
|
||||
"importButton": "Import",
|
||||
"exportButton": "Export",
|
||||
"backButton": "Back"
|
||||
"exportButton": "Export"
|
||||
},
|
||||
"import": {
|
||||
"title": "Import Cookies",
|
||||
@@ -885,6 +927,98 @@
|
||||
"json": "JSON",
|
||||
"success": "Cookies exported successfully",
|
||||
"error": "Failed to export cookies"
|
||||
},
|
||||
"paste": {
|
||||
"label": "Cookies",
|
||||
"placeholder": "Paste cookies here. JSON (an array or a {cookies: [...]} object), a Netscape cookies.txt, or name=value; name2=value2",
|
||||
"chooseFile": "or choose a file",
|
||||
"analyzing": "Checking…",
|
||||
"formatJson": "JSON",
|
||||
"formatNetscape": "Netscape",
|
||||
"formatNameValue": "Name=Value",
|
||||
"formatUnknown": "Format not recognised",
|
||||
"siteLabel": "Site",
|
||||
"sitePlaceholder": "example.com or https://example.com",
|
||||
"siteHelp": "A name=value list carries no domain of its own, so name the site these cookies belong to.",
|
||||
"scopeSubdomains": "{{domain}}: this domain and all of its subdomains",
|
||||
"scopeHostOnly": "{{domain}}: this exact host only, no subdomains",
|
||||
"modeMerge": "Merge",
|
||||
"modeMergeDesc": "Update the stored cookies that a pasted one matches, add the rest, and delete nothing.",
|
||||
"modeReplace": "Replace matching sites",
|
||||
"modeReplaceDesc": "Delete this profile's stored cookies for the sites named in this paste, in both their dotted and undotted form, then write the paste. Cookies for every other site are kept.",
|
||||
"replaceDeleteCount": "Stored cookies this would delete: {{n}}",
|
||||
"unknownCount": "unknown",
|
||||
"includeExpired": "Also import cookies that have already expired",
|
||||
"expiredNote": "Already expired in this paste: {{n}}",
|
||||
"clearsOnCloseWarning": "This profile erases its browsing data when the browser closes, so these cookies will be deleted at the end of the next session.",
|
||||
"previewTitle": "Cookies to import: {{n}}",
|
||||
"colSite": "Site",
|
||||
"colName": "Name",
|
||||
"colPath": "Path",
|
||||
"colExpires": "Expires",
|
||||
"colSecure": "Secure",
|
||||
"colHttpOnly": "HttpOnly",
|
||||
"colSameSite": "SameSite",
|
||||
"session": "Session",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"sameSiteUnspecified": "Unspecified",
|
||||
"sameSiteNone": "None",
|
||||
"sameSiteLax": "Lax",
|
||||
"sameSiteStrict": "Strict",
|
||||
"issuesTitle": "Issues",
|
||||
"showAll": "Show all {{n}}",
|
||||
"showFewer": "Show fewer",
|
||||
"sourceLine": "Line {{n}}",
|
||||
"sourceCookie": "Cookie {{n}}",
|
||||
"disabledEmpty": "Paste cookies above to import them.",
|
||||
"disabledSite": "Name the site these cookies belong to.",
|
||||
"disabledNoCookies": "No cookies could be read from this paste.",
|
||||
"resultAdded": "Added",
|
||||
"resultOverwritten": "Overwritten",
|
||||
"resultDeleted": "Deleted",
|
||||
"resultSkipped": "Skipped",
|
||||
"issues": {
|
||||
"emptyInput": "Nothing has been pasted yet.",
|
||||
"siteInvalid": "\"{{site}}\" is not a usable site and was ignored.",
|
||||
"unrecognizedFormat": "This is not JSON, a Netscape cookies.txt, or a name=value list.",
|
||||
"siteRequired": "A name=value list carries no domain. Name the site these cookies belong to.",
|
||||
"noCookiesFound": "No cookies could be read from this paste.",
|
||||
"nameEmpty": "The cookie name is empty.",
|
||||
"nameInvalid": "\"{{name}}\" is not a usable cookie name.",
|
||||
"nameMissing": "This entry has no name.",
|
||||
"valueInvalid": "The value of \"{{name}}\" holds characters a cookie cannot carry.",
|
||||
"valueCoerced": "The value of \"{{name}}\" was not text, so it was converted to text.",
|
||||
"domainFromSite": "\"{{name}}\" carried no domain and was attached to {{domain}}.",
|
||||
"domainMissing": "\"{{name}}\" carries no domain and no site was given.",
|
||||
"domainInvalid": "\"{{name}}\" names a domain that cannot be used: {{domain}}.",
|
||||
"domainAttributeIgnored": "The Domain={{domain}} attribute was ignored in favour of the site you named, {{site}}.",
|
||||
"hostOnlyMismatch": "\"{{name}}\" says hostOnly={{hostOnly}} but its domain was {{domain}}. The flag was applied.",
|
||||
"pathRepaired": "The path of \"{{name}}\" was repaired from {{path}}.",
|
||||
"expiryMilliseconds": "The expiry of \"{{name}}\" ({{expires}}) was in milliseconds and was converted to seconds.",
|
||||
"expiryClamped": "An expiry was too far in the future to be real and was clamped to the maximum.",
|
||||
"expiryInvalid": "{{field}} is not a usable timestamp: {{value}}.",
|
||||
"expiresInvalid": "Expires is not a date that can be read: {{value}}.",
|
||||
"maxAgeInvalid": "Max-Age is not a number: {{value}}.",
|
||||
"maxAgeDeletion": "The Max-Age on \"{{name}}\" deletes it immediately.",
|
||||
"sameSiteNoneInsecure": "\"{{name}}\" on {{domain}} is SameSite=None but not Secure, so the browser will refuse to send it.",
|
||||
"sameSiteUnrecognized": "SameSite \"{{value}}\" was not recognised and was left unspecified.",
|
||||
"duplicateCookie": "\"{{name}}\" for {{domain}}{{path}} appears again later in the paste. The later copy wins.",
|
||||
"boolCoercedFromString": "{{field}} was the text \"{{value}}\" instead of true or false, and was read as a boolean.",
|
||||
"boolInvalid": "{{field}} is neither true nor false: {{value}}.",
|
||||
"quotedValue": "The quotes around the value of \"{{name}}\" were removed.",
|
||||
"jsonParseFailed": "The JSON could not be read: {{message}}",
|
||||
"jsonNotCookieList": "The JSON is neither an array of cookies nor an object holding a cookies array.",
|
||||
"jsonEntryNotObject": "This entry is not a JSON object.",
|
||||
"netscapePathOmitted": "This line has no path column, so / was used.",
|
||||
"netscapeFieldCount": "This line has {{actual}} columns; a Netscape cookie line has {{expected}}.",
|
||||
"netscapeIncludeSubdomainsInvalid": "The include-subdomains column is neither TRUE nor FALSE: {{value}}.",
|
||||
"netscapeSecureInvalid": "The secure column is neither TRUE nor FALSE: {{value}}.",
|
||||
"netscapeExpiryInvalid": "The expiry column is not a number: {{value}}. The line was dropped rather than turned into a live cookie.",
|
||||
"nameValueNoPair": "This part has no name=value pair and was ignored.",
|
||||
"pairTreatedAsAttribute": "\"{{name}}\" was read as a Set-Cookie attribute rather than a cookie, and its value was discarded.",
|
||||
"unknown": "{{code}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
@@ -994,6 +1128,7 @@
|
||||
"autoLocationDescription": "Automatically configure location information based on proxy configuration or your connection if no proxy provided",
|
||||
"editingDisabledRunning": "Fingerprint editing is disabled because the profile is currently running. Stop the profile to make changes.",
|
||||
"editingDisabledRandomized": "Fingerprint editing is disabled because random fingerprint generation is enabled. Disable the option above to manually edit the fingerprint configuration.",
|
||||
"derivedFromIdentity": "Derived from this profile's identity and not editable.",
|
||||
"advancedWarning": "Warning: Only edit these parameters if you know what you're doing. Incorrect values may break websites, make them detect you, and lead to hard-to-debug bugs.",
|
||||
"basicWarning": "Warning: Only edit these parameters if you know what you're doing.",
|
||||
"automatic": "Automatic",
|
||||
@@ -1083,7 +1218,9 @@
|
||||
"brandVersion": "Brand Version",
|
||||
"proFeature": "This is a Pro feature",
|
||||
"generateFingerprint": "Generate Fingerprint",
|
||||
"refreshFingerprint": "Refresh Fingerprint",
|
||||
"regenerateFingerprint": "Regenerate Fingerprint",
|
||||
"regenerateConfirmTitle": "Regenerate this fingerprint?",
|
||||
"regenerateConfirmDescription": "The profile keeps its cookies and logins but will present a different device. Sites that already know this profile can ask you to sign in again, challenge you, or block the account. Only regenerate a profile you have not used yet, or one you are willing to lose. This cannot be undone.",
|
||||
"canvasNoiseSeedPlaceholder": "Enter a seed string for canvas fingerprint",
|
||||
"addFontsPlaceholder": "Add fonts...",
|
||||
"enterAsJson": "Enter {{title}} as JSON"
|
||||
@@ -1881,6 +2018,10 @@
|
||||
"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.",
|
||||
"cookieImportBrowserRunning": "Cannot import cookies while the browser is running. Close it and try again.",
|
||||
"cookieImportProfileProtected": "Cannot import cookies into a password-protected profile. Remove the password first.",
|
||||
"cookieImportRemoteSession": "Cannot import cookies while a remote session owns this profile. Wait for it to finish syncing.",
|
||||
"cookieImportNoCookies": "No cookies were found in what you pasted.",
|
||||
"selfHostedRequiresLogout": "Sign out of your Donut account before configuring a self-hosted server.",
|
||||
"fingerprintRequiresPro": "Viewing or editing the fingerprint requires an active paid plan. Protection is included on all plans.",
|
||||
"proxyNotWorking": "The selected proxy isn't working, so the profile wasn't created.",
|
||||
@@ -1889,6 +2030,8 @@
|
||||
"camoufoxImportDeprecated": "Importing this profile type is no longer supported. Please use Wayfern instead.",
|
||||
"updateChecksumsUnavailable": "The update {{version}} could not be verified because its checksum file could not be retrieved. The update was not installed; it will be retried later.",
|
||||
"updateChecksumMismatch": "The downloaded update file {{file}} failed checksum verification and was discarded. Please try again.",
|
||||
"browserChecksumUnavailable": "{{browser}} {{version}} could not be verified because its checksum file could not be retrieved. The download was stopped; it will be retried later.",
|
||||
"browserChecksumMismatch": "The downloaded {{browser}} {{version}} archive failed checksum verification and was discarded. Please try again.",
|
||||
"nameCannotBeEmpty": "Name cannot be empty",
|
||||
"wayfernVersionNotAvailable": "Wayfern version {{requested}} is not available for download. The current version is {{current}}.",
|
||||
"profileNameExists": "A profile named \"{{name}}\" already exists",
|
||||
@@ -1977,7 +2120,11 @@
|
||||
"noE2ePasswordSet": "No end-to-end encryption password is set. Set one before syncing encrypted data.",
|
||||
"importSourceNotChromium": "This folder is not a Chromium browser profile",
|
||||
"importSourceNotChromiumNamed": "{{family}} profiles cannot be imported; only Chromium-based browsers are supported",
|
||||
"importSourceBrowserRunning": "Close {{browser}} first, or choose to import anyway"
|
||||
"importSourceBrowserRunning": "Close {{browser}} first, or choose to import anyway",
|
||||
"wayfernFingerprintApplyFailed": "Could not apply this profile's fingerprint, so the browser was not started. {{detail}}",
|
||||
"wayfernFingerprintGenerationFailed": "Could not create a fingerprint for this profile. {{detail}}",
|
||||
"wayfernGenerationLimitReached": "The fingerprint generation limit for this account has been reached. Your existing profiles will keep launching normally — only new fingerprints are paused, and they become available again a little later.",
|
||||
"wayfernCrossOsRequiresPlan": "This profile claims {{detail}}, which needs a paid plan and an active sign-in. Sign in or switch the profile to your own operating system."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiles",
|
||||
|
||||
+156
-14
@@ -134,7 +134,11 @@
|
||||
"title": "Navegador Predeterminado",
|
||||
"setAsDefault": "Establecer como Navegador Predeterminado",
|
||||
"alreadyDefault": "Ya es el Navegador Predeterminado",
|
||||
"description": "Cuando se establece como predeterminado, Donut Browser manejará los enlaces web y te permitirá elegir qué perfil usar."
|
||||
"description": "Cuando se establece como predeterminado, Donut Browser manejará los enlaces web y te permitirá elegir qué perfil usar.",
|
||||
"setSuccess": "Donut Browser ya es tu navegador predeterminado",
|
||||
"setFailed": "No se pudo establecer el navegador predeterminado",
|
||||
"finishInSystemSettings": "Termina en la Configuración de Windows",
|
||||
"finishInSystemSettingsDescription": "Donut Browser está registrado. Se abrió la Configuración de Windows: elige Donut Browser en Navegador web para terminar."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Permisos del Sistema",
|
||||
@@ -228,6 +232,49 @@
|
||||
"scrollGroupsLeft": "Desplazar grupos a la izquierda",
|
||||
"scrollGroupsRight": "Desplazar grupos a la derecha"
|
||||
},
|
||||
"search": {
|
||||
"helpLabel": "Sintaxis de búsqueda",
|
||||
"helpTitle": "Sintaxis de búsqueda",
|
||||
"helpIntro": "Escribe palabras para buscar en nombres, notas, etiquetas e ids. Añade campos para acotar más.",
|
||||
"fieldsTitle": "Campos",
|
||||
"operatorsTitle": "Operadores",
|
||||
"examplesTitle": "Ejemplos",
|
||||
"fields": {
|
||||
"name": "Nombre del perfil",
|
||||
"tag": "Etiqueta",
|
||||
"note": "Nota",
|
||||
"id": "Id del perfil, desde el principio",
|
||||
"group": "Nombre del grupo",
|
||||
"proxy": "Nombre del proxy",
|
||||
"vpn": "Nombre de la VPN",
|
||||
"ext": "Nombre del grupo de extensiones",
|
||||
"dns": "Lista de bloqueo DNS",
|
||||
"os": "Sistema operativo",
|
||||
"browser": "Navegador",
|
||||
"status": "En ejecución o no",
|
||||
"sync": "Modo de sincronización",
|
||||
"email": "Correo del propietario",
|
||||
"version": "Versión del navegador",
|
||||
"locked": "Protegido con contraseña",
|
||||
"ephemeral": "Perfil efímero",
|
||||
"created": "Fecha de creación",
|
||||
"launched": "Fecha del último inicio"
|
||||
},
|
||||
"operators": {
|
||||
"negate": "Excluye lo que coincide",
|
||||
"quote": "Mantiene unido un valor con espacios",
|
||||
"or": "Coincide con cualquiera de los dos términos",
|
||||
"comma": "Atajo para cualquiera de los valores",
|
||||
"exact": "Coincide con el valor completo, no con una parte",
|
||||
"none": "Aquí no hay nada definido; usa any para lo contrario",
|
||||
"compare": "Compara fechas y versiones; 7d, 3w y 6m cuentan hacia atrás desde ahora"
|
||||
},
|
||||
"examples": {
|
||||
"a": "Perfiles en ejecución de un grupo",
|
||||
"b": "Perfiles sin etiquetas que tienen proxy",
|
||||
"c": "Sin iniciar desde hace más de 30 días, ignorando los archivados"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"title": "Perfiles",
|
||||
"empty": "Sin perfiles aún",
|
||||
@@ -237,6 +284,7 @@
|
||||
"noResultsDescription": "Ningún perfil coincide con tus criterios de búsqueda.",
|
||||
"table": {
|
||||
"name": "Nombre",
|
||||
"none": "Ninguno",
|
||||
"browser": "Navegador",
|
||||
"status": "Estado",
|
||||
"actions": "Acciones",
|
||||
@@ -245,10 +293,8 @@
|
||||
"proxy": "Proxy / VPN",
|
||||
"lastLaunch": "Último Inicio",
|
||||
"empty": "No se encontraron perfiles.",
|
||||
"notSelected": "No seleccionado",
|
||||
"ext": "EXT",
|
||||
"dns": "DNS",
|
||||
"extDefault": "Predet.",
|
||||
"dnsLevel": "Lista DNS: {{level}}",
|
||||
"extSearch": "Buscar grupos…",
|
||||
"extEmpty": "Sin grupos de extensiones",
|
||||
@@ -262,7 +308,8 @@
|
||||
"emptyImport": "Importar perfiles",
|
||||
"emptyFilteredTitle": "No se encontraron perfiles",
|
||||
"emptyFilteredHint": "Ningún perfil coincide con este grupo o búsqueda. Prueba otro filtro o crea uno nuevo.",
|
||||
"bot": "Bot"
|
||||
"bot": "Bot",
|
||||
"profileId": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Iniciar",
|
||||
@@ -637,6 +684,8 @@
|
||||
"serverError": "El servidor respondió con un error",
|
||||
"connectFailed": "Error al conectar con el servidor",
|
||||
"storageEndpoint": "Almacenamiento: {{endpoint}}",
|
||||
"storageUnreachableStatus": "Almacenamiento inaccesible",
|
||||
"storageUnreachable": "Se puede acceder al servidor, pero no a su dirección de almacenamiento {{endpoint}} desde este dispositivo. Las transferencias de archivos fallarán. Si usas un servidor propio, configura S3_PUBLIC_ENDPOINT con una dirección accesible desde este dispositivo.",
|
||||
"settingsSaved": "Ajustes de sincronización guardados",
|
||||
"saveFailed": "Error al guardar los ajustes",
|
||||
"disconnected": "Sincronización desconectada",
|
||||
@@ -853,12 +902,7 @@
|
||||
"menuItem": "Gestión de Cookies",
|
||||
"tabImport": "Importar",
|
||||
"tabExport": "Exportar",
|
||||
"importDescription": "Importa cookies desde un archivo en formato Netscape o JSON.",
|
||||
"dropPrompt": "Haz clic para elegir un archivo de cookies",
|
||||
"fileFormats": "(.txt, .cookies o .json)",
|
||||
"cookiesFound": "{{count}} cookies encontradas",
|
||||
"importedSuccess": "{{imported}} cookies importadas correctamente ({{replaced}} reemplazadas)",
|
||||
"linesSkipped": "{{count}} línea(s) omitidas",
|
||||
"importDescription": "Pega las cookies copiadas de otro navegador o herramienta, o elige un archivo.",
|
||||
"fileReadError": "Error al leer el archivo",
|
||||
"loadFailed": "Error al cargar las cookies: {{error}}",
|
||||
"cookiesLabel": "Cookies",
|
||||
@@ -867,9 +911,7 @@
|
||||
"deselectAll": "Deseleccionar todo",
|
||||
"noCookies": "No se encontraron cookies en este perfil",
|
||||
"doneButton": "Hecho",
|
||||
"importButton": "Importar",
|
||||
"exportButton": "Exportar",
|
||||
"backButton": "Atrás"
|
||||
"exportButton": "Exportar"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importar Cookies",
|
||||
@@ -888,6 +930,98 @@
|
||||
"json": "JSON",
|
||||
"success": "Cookies exportadas exitosamente",
|
||||
"error": "Error al exportar cookies"
|
||||
},
|
||||
"paste": {
|
||||
"label": "Cookies",
|
||||
"placeholder": "Pega las cookies aquí. JSON (un arreglo o un objeto {cookies: [...]}), un cookies.txt de Netscape, o nombre=valor; nombre2=valor2",
|
||||
"chooseFile": "o elige un archivo",
|
||||
"analyzing": "Comprobando…",
|
||||
"formatJson": "JSON",
|
||||
"formatNetscape": "Netscape",
|
||||
"formatNameValue": "Nombre=Valor",
|
||||
"formatUnknown": "Formato no reconocido",
|
||||
"siteLabel": "Sitio",
|
||||
"sitePlaceholder": "ejemplo.com o https://ejemplo.com",
|
||||
"siteHelp": "Una lista nombre=valor no lleva dominio propio, así que indica el sitio al que pertenecen estas cookies.",
|
||||
"scopeSubdomains": "{{domain}}: este dominio y todos sus subdominios",
|
||||
"scopeHostOnly": "{{domain}}: solo este host exacto, sin subdominios",
|
||||
"modeMerge": "Combinar",
|
||||
"modeMergeDesc": "Actualiza las cookies guardadas que coincidan con una pegada, añade las demás y no borra nada.",
|
||||
"modeReplace": "Reemplazar los sitios coincidentes",
|
||||
"modeReplaceDesc": "Borra las cookies guardadas de este perfil para los sitios indicados en este pegado, tanto en su forma con punto como sin punto, y luego escribe el pegado. Las cookies de los demás sitios se conservan.",
|
||||
"replaceDeleteCount": "Cookies guardadas que se borrarían: {{n}}",
|
||||
"unknownCount": "desconocido",
|
||||
"includeExpired": "Importar también las cookies ya caducadas",
|
||||
"expiredNote": "Ya caducadas en este pegado: {{n}}",
|
||||
"clearsOnCloseWarning": "Este perfil borra sus datos de navegación al cerrar el navegador, así que estas cookies se eliminarán al final de la próxima sesión.",
|
||||
"previewTitle": "Cookies por importar: {{n}}",
|
||||
"colSite": "Sitio",
|
||||
"colName": "Nombre",
|
||||
"colPath": "Ruta",
|
||||
"colExpires": "Caduca",
|
||||
"colSecure": "Secure",
|
||||
"colHttpOnly": "HttpOnly",
|
||||
"colSameSite": "SameSite",
|
||||
"session": "Sesión",
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
"sameSiteUnspecified": "Sin especificar",
|
||||
"sameSiteNone": "None",
|
||||
"sameSiteLax": "Lax",
|
||||
"sameSiteStrict": "Strict",
|
||||
"issuesTitle": "Incidencias",
|
||||
"showAll": "Mostrar las {{n}}",
|
||||
"showFewer": "Mostrar menos",
|
||||
"sourceLine": "Línea {{n}}",
|
||||
"sourceCookie": "Cookie {{n}}",
|
||||
"disabledEmpty": "Pega cookies arriba para importarlas.",
|
||||
"disabledSite": "Indica el sitio al que pertenecen estas cookies.",
|
||||
"disabledNoCookies": "No se pudo leer ninguna cookie de este pegado.",
|
||||
"resultAdded": "Añadidas",
|
||||
"resultOverwritten": "Sobrescritas",
|
||||
"resultDeleted": "Borradas",
|
||||
"resultSkipped": "Omitidas",
|
||||
"issues": {
|
||||
"emptyInput": "Todavía no se ha pegado nada.",
|
||||
"siteInvalid": "\"{{site}}\" no es un sitio válido y se ignoró.",
|
||||
"unrecognizedFormat": "Esto no es JSON, ni un cookies.txt de Netscape, ni una lista nombre=valor.",
|
||||
"siteRequired": "Una lista nombre=valor no lleva dominio. Indica el sitio al que pertenecen estas cookies.",
|
||||
"noCookiesFound": "No se pudo leer ninguna cookie de este pegado.",
|
||||
"nameEmpty": "El nombre de la cookie está vacío.",
|
||||
"nameInvalid": "\"{{name}}\" no es un nombre de cookie válido.",
|
||||
"nameMissing": "Esta entrada no tiene nombre.",
|
||||
"valueInvalid": "El valor de \"{{name}}\" contiene caracteres que una cookie no puede llevar.",
|
||||
"valueCoerced": "El valor de \"{{name}}\" no era texto, así que se convirtió a texto.",
|
||||
"domainFromSite": "\"{{name}}\" no llevaba dominio y se asoció a {{domain}}.",
|
||||
"domainMissing": "\"{{name}}\" no lleva dominio y no se indicó ningún sitio.",
|
||||
"domainInvalid": "\"{{name}}\" indica un dominio que no se puede usar: {{domain}}.",
|
||||
"domainAttributeIgnored": "El atributo Domain={{domain}} se ignoró en favor del sitio que indicaste, {{site}}.",
|
||||
"hostOnlyMismatch": "\"{{name}}\" declara hostOnly={{hostOnly}} pero su dominio era {{domain}}. Se aplicó la marca.",
|
||||
"pathRepaired": "La ruta de \"{{name}}\" se corrigió a partir de {{path}}.",
|
||||
"expiryMilliseconds": "La caducidad de \"{{name}}\" ({{expires}}) estaba en milisegundos y se convirtió a segundos.",
|
||||
"expiryClamped": "Una caducidad estaba demasiado lejos en el futuro para ser real y se limitó al máximo.",
|
||||
"expiryInvalid": "{{field}} no es una marca de tiempo válida: {{value}}.",
|
||||
"expiresInvalid": "Expires no es una fecha que se pueda leer: {{value}}.",
|
||||
"maxAgeInvalid": "Max-Age no es un número: {{value}}.",
|
||||
"maxAgeDeletion": "El Max-Age de \"{{name}}\" la borra de inmediato.",
|
||||
"sameSiteNoneInsecure": "\"{{name}}\" en {{domain}} es SameSite=None pero no Secure, así que el navegador se negará a enviarla.",
|
||||
"sameSiteUnrecognized": "No se reconoció el SameSite \"{{value}}\" y se dejó sin especificar.",
|
||||
"duplicateCookie": "\"{{name}}\" para {{domain}}{{path}} vuelve a aparecer más adelante en el pegado. Gana la copia posterior.",
|
||||
"boolCoercedFromString": "{{field}} era el texto \"{{value}}\" en lugar de true o false, y se leyó como booleano.",
|
||||
"boolInvalid": "{{field}} no es ni true ni false: {{value}}.",
|
||||
"quotedValue": "Se quitaron las comillas del valor de \"{{name}}\".",
|
||||
"jsonParseFailed": "No se pudo leer el JSON: {{message}}",
|
||||
"jsonNotCookieList": "El JSON no es ni un arreglo de cookies ni un objeto que contenga un arreglo cookies.",
|
||||
"jsonEntryNotObject": "Esta entrada no es un objeto JSON.",
|
||||
"netscapePathOmitted": "Esta línea no tiene columna de ruta, así que se usó /.",
|
||||
"netscapeFieldCount": "Esta línea tiene {{actual}} columnas; una línea de cookie Netscape tiene {{expected}}.",
|
||||
"netscapeIncludeSubdomainsInvalid": "La columna de incluir subdominios no es ni TRUE ni FALSE: {{value}}.",
|
||||
"netscapeSecureInvalid": "La columna secure no es ni TRUE ni FALSE: {{value}}.",
|
||||
"netscapeExpiryInvalid": "La columna de caducidad no es un número: {{value}}. Se descartó la línea en lugar de convertirla en una cookie activa.",
|
||||
"nameValueNoPair": "Esta parte no tiene un par nombre=valor y se ignoró.",
|
||||
"pairTreatedAsAttribute": "\"{{name}}\" se leyó como un atributo de Set-Cookie en lugar de una cookie, y su valor se descartó.",
|
||||
"unknown": "{{code}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
@@ -1086,7 +1220,9 @@
|
||||
"brandVersion": "Versión de marca",
|
||||
"proFeature": "Esta es una función Pro",
|
||||
"generateFingerprint": "Generar Huella Digital",
|
||||
"refreshFingerprint": "Actualizar Huella Digital",
|
||||
"regenerateFingerprint": "Regenerar Huella Digital",
|
||||
"regenerateConfirmTitle": "¿Regenerar esta huella digital?",
|
||||
"regenerateConfirmDescription": "El perfil conserva sus cookies y sesiones, pero mostrará un dispositivo distinto. Los sitios que ya conocen este perfil pueden pedirte iniciar sesión de nuevo, someterte a una verificación o bloquear la cuenta. Regenera solo un perfil que aún no hayas usado o que estés dispuesto a perder. Esta acción no se puede deshacer.",
|
||||
"canvasNoiseSeedPlaceholder": "Introduce una semilla para la huella digital del canvas",
|
||||
"addFontsPlaceholder": "Agregar fuentes...",
|
||||
"enterAsJson": "Ingresa {{title}} como JSON"
|
||||
@@ -1888,6 +2024,10 @@
|
||||
"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.",
|
||||
"cookieImportBrowserRunning": "No se pueden importar cookies mientras el navegador está en ejecución. Ciérralo e inténtalo de nuevo.",
|
||||
"cookieImportProfileProtected": "No se pueden importar cookies en un perfil protegido con contraseña. Quita primero la contraseña.",
|
||||
"cookieImportRemoteSession": "No se pueden importar cookies mientras una sesión remota controla este perfil. Espera a que termine de sincronizarse.",
|
||||
"cookieImportNoCookies": "No se encontraron cookies en lo que pegaste.",
|
||||
"selfHostedRequiresLogout": "Cierra sesión en tu cuenta de Donut antes de configurar un servidor autoalojado.",
|
||||
"fingerprintRequiresPro": "Ver o editar la huella digital requiere un plan de pago activo. La protección está incluida en todos los planes.",
|
||||
"proxyNotWorking": "El proxy seleccionado no funciona, por lo que no se creó el perfil.",
|
||||
@@ -1896,6 +2036,8 @@
|
||||
"camoufoxImportDeprecated": "La importación de este tipo de perfil ya no es compatible. Utiliza Wayfern en su lugar.",
|
||||
"updateChecksumsUnavailable": "No se pudo verificar la actualización {{version}} porque no se pudo obtener su archivo de sumas de comprobación. La actualización no se instaló; se reintentará más tarde.",
|
||||
"updateChecksumMismatch": "El archivo de actualización descargado {{file}} no superó la verificación de suma de comprobación y fue descartado. Inténtalo de nuevo.",
|
||||
"browserChecksumUnavailable": "No se pudo verificar {{browser}} {{version}} porque no se pudo obtener su archivo de sumas de comprobación. La descarga se detuvo; se reintentará más tarde.",
|
||||
"browserChecksumMismatch": "El archivo descargado de {{browser}} {{version}} no superó la verificación de suma de comprobación y fue descartado. Inténtalo de nuevo.",
|
||||
"nameCannotBeEmpty": "El nombre no puede estar vacío",
|
||||
"wayfernVersionNotAvailable": "La versión {{requested}} de Wayfern no está disponible para descargar. La versión actual es {{current}}.",
|
||||
"profileNameExists": "Ya existe un perfil llamado \"{{name}}\"",
|
||||
|
||||
+156
-14
@@ -134,7 +134,11 @@
|
||||
"title": "Navigateur par défaut",
|
||||
"setAsDefault": "Définir comme navigateur par défaut",
|
||||
"alreadyDefault": "Déjà le navigateur par défaut",
|
||||
"description": "Lorsqu'il est défini par défaut, Donut Browser gérera les liens web et vous permettra de choisir quel profil utiliser."
|
||||
"description": "Lorsqu'il est défini par défaut, Donut Browser gérera les liens web et vous permettra de choisir quel profil utiliser.",
|
||||
"setSuccess": "Donut Browser est maintenant votre navigateur par défaut",
|
||||
"setFailed": "Impossible de définir le navigateur par défaut",
|
||||
"finishInSystemSettings": "Terminez dans les Paramètres Windows",
|
||||
"finishInSystemSettingsDescription": "Donut Browser est enregistré. Les Paramètres Windows sont ouverts : choisissez Donut Browser sous Navigateur web pour terminer."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Permissions système",
|
||||
@@ -228,6 +232,49 @@
|
||||
"scrollGroupsLeft": "Faire défiler les groupes vers la gauche",
|
||||
"scrollGroupsRight": "Faire défiler les groupes vers la droite"
|
||||
},
|
||||
"search": {
|
||||
"helpLabel": "Syntaxe de recherche",
|
||||
"helpTitle": "Syntaxe de recherche",
|
||||
"helpIntro": "Tapez des mots pour chercher dans les noms, les notes, les étiquettes et les ids. Ajoutez des champs pour affiner.",
|
||||
"fieldsTitle": "Champs",
|
||||
"operatorsTitle": "Opérateurs",
|
||||
"examplesTitle": "Exemples",
|
||||
"fields": {
|
||||
"name": "Nom du profil",
|
||||
"tag": "Étiquette",
|
||||
"note": "Note",
|
||||
"id": "Id du profil, à partir du début",
|
||||
"group": "Nom du groupe",
|
||||
"proxy": "Nom du proxy",
|
||||
"vpn": "Nom du VPN",
|
||||
"ext": "Nom du groupe d'extensions",
|
||||
"dns": "Liste de blocage DNS",
|
||||
"os": "Système d'exploitation",
|
||||
"browser": "Navigateur",
|
||||
"status": "En cours d'exécution ou non",
|
||||
"sync": "Mode de synchronisation",
|
||||
"email": "E-mail du propriétaire",
|
||||
"version": "Version du navigateur",
|
||||
"locked": "Protégé par mot de passe",
|
||||
"ephemeral": "Profil éphémère",
|
||||
"created": "Date de création",
|
||||
"launched": "Date du dernier lancement"
|
||||
},
|
||||
"operators": {
|
||||
"negate": "Exclut ce qui correspond",
|
||||
"quote": "Garde ensemble une valeur contenant des espaces",
|
||||
"or": "Correspond à l'un ou l'autre terme",
|
||||
"comma": "Raccourci pour l'une ou l'autre valeur",
|
||||
"exact": "Correspond à la valeur entière, pas à une partie",
|
||||
"none": "Rien de défini ici ; utilisez any pour l'inverse",
|
||||
"compare": "Compare les dates et les versions ; 7d, 3w et 6m comptent à rebours depuis maintenant"
|
||||
},
|
||||
"examples": {
|
||||
"a": "Profils en cours d'exécution dans un groupe",
|
||||
"b": "Profils sans étiquette qui ont un proxy",
|
||||
"c": "Non lancés depuis plus de 30 jours, en ignorant les archivés"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"title": "Profils",
|
||||
"empty": "Aucun profil pour l'instant",
|
||||
@@ -237,6 +284,7 @@
|
||||
"noResultsDescription": "Aucun profil ne correspond à vos critères de recherche.",
|
||||
"table": {
|
||||
"name": "Nom",
|
||||
"none": "Aucun",
|
||||
"browser": "Navigateur",
|
||||
"status": "Statut",
|
||||
"actions": "Actions",
|
||||
@@ -245,10 +293,8 @@
|
||||
"proxy": "Proxy / VPN",
|
||||
"lastLaunch": "Dernier lancement",
|
||||
"empty": "Aucun profil trouvé.",
|
||||
"notSelected": "Non sélectionné",
|
||||
"ext": "EXT",
|
||||
"dns": "DNS",
|
||||
"extDefault": "Défaut",
|
||||
"dnsLevel": "Liste DNS : {{level}}",
|
||||
"extSearch": "Rechercher des groupes…",
|
||||
"extEmpty": "Aucun groupe d’extensions",
|
||||
@@ -262,7 +308,8 @@
|
||||
"emptyImport": "Importer des profils",
|
||||
"emptyFilteredTitle": "Aucun profil trouvé",
|
||||
"emptyFilteredHint": "Aucun profil ne correspond à ce groupe ou à cette recherche. Essayez un autre filtre ou créez-en un.",
|
||||
"bot": "Bot"
|
||||
"bot": "Bot",
|
||||
"profileId": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Lancer",
|
||||
@@ -637,6 +684,8 @@
|
||||
"serverError": "Le serveur a répondu avec une erreur",
|
||||
"connectFailed": "Échec de la connexion au serveur",
|
||||
"storageEndpoint": "Stockage : {{endpoint}}",
|
||||
"storageUnreachableStatus": "Stockage inaccessible",
|
||||
"storageUnreachable": "Le serveur est accessible, mais son adresse de stockage {{endpoint}} est inaccessible depuis cet appareil. Les transferts de fichiers échoueront. Si vous l'hébergez vous-même, définissez S3_PUBLIC_ENDPOINT sur une adresse accessible depuis cet appareil.",
|
||||
"settingsSaved": "Paramètres de synchronisation enregistrés",
|
||||
"saveFailed": "Échec de l’enregistrement des paramètres",
|
||||
"disconnected": "Synchronisation déconnectée",
|
||||
@@ -853,12 +902,7 @@
|
||||
"menuItem": "Gestion des Cookies",
|
||||
"tabImport": "Importer",
|
||||
"tabExport": "Exporter",
|
||||
"importDescription": "Importer des cookies depuis un fichier au format Netscape ou JSON.",
|
||||
"dropPrompt": "Cliquez pour choisir un fichier de cookies",
|
||||
"fileFormats": "(.txt, .cookies ou .json)",
|
||||
"cookiesFound": "{{count}} cookies trouvés",
|
||||
"importedSuccess": "{{imported}} cookies importés avec succès ({{replaced}} remplacés)",
|
||||
"linesSkipped": "{{count}} ligne(s) ignorée(s)",
|
||||
"importDescription": "Collez les cookies copiés depuis un autre navigateur ou outil, ou choisissez un fichier.",
|
||||
"fileReadError": "Échec de la lecture du fichier",
|
||||
"loadFailed": "Échec du chargement des cookies : {{error}}",
|
||||
"cookiesLabel": "Cookies",
|
||||
@@ -867,9 +911,7 @@
|
||||
"deselectAll": "Tout désélectionner",
|
||||
"noCookies": "Aucun cookie trouvé dans ce profil",
|
||||
"doneButton": "Terminé",
|
||||
"importButton": "Importer",
|
||||
"exportButton": "Exporter",
|
||||
"backButton": "Retour"
|
||||
"exportButton": "Exporter"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importer des Cookies",
|
||||
@@ -888,6 +930,98 @@
|
||||
"json": "JSON",
|
||||
"success": "Cookies exportés avec succès",
|
||||
"error": "Échec de l'exportation des cookies"
|
||||
},
|
||||
"paste": {
|
||||
"label": "Cookies",
|
||||
"placeholder": "Collez les cookies ici. JSON (un tableau ou un objet {cookies: [...]}), un cookies.txt Netscape, ou nom=valeur; nom2=valeur2",
|
||||
"chooseFile": "ou choisissez un fichier",
|
||||
"analyzing": "Vérification…",
|
||||
"formatJson": "JSON",
|
||||
"formatNetscape": "Netscape",
|
||||
"formatNameValue": "Nom=Valeur",
|
||||
"formatUnknown": "Format non reconnu",
|
||||
"siteLabel": "Site",
|
||||
"sitePlaceholder": "exemple.com ou https://exemple.com",
|
||||
"siteHelp": "Une liste nom=valeur ne porte aucun domaine, indiquez donc le site auquel ces cookies appartiennent.",
|
||||
"scopeSubdomains": "{{domain}} : ce domaine et tous ses sous-domaines",
|
||||
"scopeHostOnly": "{{domain}} : uniquement cet hôte exact, sans sous-domaines",
|
||||
"modeMerge": "Fusionner",
|
||||
"modeMergeDesc": "Met à jour les cookies enregistrés qu'un cookie collé fait correspondre, ajoute les autres et n'en supprime aucun.",
|
||||
"modeReplace": "Remplacer les sites correspondants",
|
||||
"modeReplaceDesc": "Supprime les cookies enregistrés de ce profil pour les sites nommés dans ce collage, sous leur forme avec point et sans point, puis écrit le collage. Les cookies de tous les autres sites sont conservés.",
|
||||
"replaceDeleteCount": "Cookies enregistrés qui seraient supprimés : {{n}}",
|
||||
"unknownCount": "inconnu",
|
||||
"includeExpired": "Importer aussi les cookies déjà expirés",
|
||||
"expiredNote": "Déjà expirés dans ce collage : {{n}}",
|
||||
"clearsOnCloseWarning": "Ce profil efface ses données de navigation à la fermeture du navigateur, donc ces cookies seront supprimés à la fin de la prochaine session.",
|
||||
"previewTitle": "Cookies à importer : {{n}}",
|
||||
"colSite": "Site",
|
||||
"colName": "Nom",
|
||||
"colPath": "Chemin",
|
||||
"colExpires": "Expiration",
|
||||
"colSecure": "Secure",
|
||||
"colHttpOnly": "HttpOnly",
|
||||
"colSameSite": "SameSite",
|
||||
"session": "Session",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"sameSiteUnspecified": "Non précisé",
|
||||
"sameSiteNone": "None",
|
||||
"sameSiteLax": "Lax",
|
||||
"sameSiteStrict": "Strict",
|
||||
"issuesTitle": "Anomalies",
|
||||
"showAll": "Afficher les {{n}}",
|
||||
"showFewer": "Afficher moins",
|
||||
"sourceLine": "Ligne {{n}}",
|
||||
"sourceCookie": "Cookie {{n}}",
|
||||
"disabledEmpty": "Collez des cookies ci-dessus pour les importer.",
|
||||
"disabledSite": "Indiquez le site auquel ces cookies appartiennent.",
|
||||
"disabledNoCookies": "Aucun cookie n'a pu être lu dans ce collage.",
|
||||
"resultAdded": "Ajoutés",
|
||||
"resultOverwritten": "Écrasés",
|
||||
"resultDeleted": "Supprimés",
|
||||
"resultSkipped": "Ignorés",
|
||||
"issues": {
|
||||
"emptyInput": "Rien n'a encore été collé.",
|
||||
"siteInvalid": "« {{site}} » n'est pas un site exploitable et a été ignoré.",
|
||||
"unrecognizedFormat": "Ceci n'est ni du JSON, ni un cookies.txt Netscape, ni une liste nom=valeur.",
|
||||
"siteRequired": "Une liste nom=valeur ne porte aucun domaine. Indiquez le site auquel ces cookies appartiennent.",
|
||||
"noCookiesFound": "Aucun cookie n'a pu être lu dans ce collage.",
|
||||
"nameEmpty": "Le nom du cookie est vide.",
|
||||
"nameInvalid": "« {{name}} » n'est pas un nom de cookie exploitable.",
|
||||
"nameMissing": "Cette entrée n'a pas de nom.",
|
||||
"valueInvalid": "La valeur de « {{name}} » contient des caractères qu'un cookie ne peut pas porter.",
|
||||
"valueCoerced": "La valeur de « {{name}} » n'était pas du texte, elle a donc été convertie en texte.",
|
||||
"domainFromSite": "« {{name}} » ne portait aucun domaine et a été rattaché à {{domain}}.",
|
||||
"domainMissing": "« {{name}} » ne porte aucun domaine et aucun site n'a été indiqué.",
|
||||
"domainInvalid": "« {{name}} » désigne un domaine inutilisable : {{domain}}.",
|
||||
"domainAttributeIgnored": "L'attribut Domain={{domain}} a été ignoré au profit du site que vous avez indiqué, {{site}}.",
|
||||
"hostOnlyMismatch": "« {{name}} » annonce hostOnly={{hostOnly}} alors que son domaine était {{domain}}. L'indicateur a été appliqué.",
|
||||
"pathRepaired": "Le chemin de « {{name}} » a été corrigé à partir de {{path}}.",
|
||||
"expiryMilliseconds": "L'expiration de « {{name}} » ({{expires}}) était en millisecondes et a été convertie en secondes.",
|
||||
"expiryClamped": "Une expiration était trop lointaine pour être réelle et a été ramenée au maximum.",
|
||||
"expiryInvalid": "{{field}} n'est pas un horodatage exploitable : {{value}}.",
|
||||
"expiresInvalid": "Expires n'est pas une date lisible : {{value}}.",
|
||||
"maxAgeInvalid": "Max-Age n'est pas un nombre : {{value}}.",
|
||||
"maxAgeDeletion": "Le Max-Age de « {{name}} » le supprime immédiatement.",
|
||||
"sameSiteNoneInsecure": "« {{name}} » sur {{domain}} est SameSite=None mais pas Secure, le navigateur refusera donc de l'envoyer.",
|
||||
"sameSiteUnrecognized": "Le SameSite « {{value}} » n'a pas été reconnu et est resté non précisé.",
|
||||
"duplicateCookie": "« {{name}} » pour {{domain}}{{path}} réapparaît plus loin dans le collage. La dernière copie l'emporte.",
|
||||
"boolCoercedFromString": "{{field}} était le texte « {{value}} » au lieu de true ou false, et a été lu comme un booléen.",
|
||||
"boolInvalid": "{{field}} n'est ni true ni false : {{value}}.",
|
||||
"quotedValue": "Les guillemets autour de la valeur de « {{name}} » ont été retirés.",
|
||||
"jsonParseFailed": "Le JSON n'a pas pu être lu : {{message}}",
|
||||
"jsonNotCookieList": "Le JSON n'est ni un tableau de cookies ni un objet contenant un tableau cookies.",
|
||||
"jsonEntryNotObject": "Cette entrée n'est pas un objet JSON.",
|
||||
"netscapePathOmitted": "Cette ligne n'a pas de colonne de chemin, / a donc été utilisé.",
|
||||
"netscapeFieldCount": "Cette ligne a {{actual}} colonnes ; une ligne de cookie Netscape en a {{expected}}.",
|
||||
"netscapeIncludeSubdomainsInvalid": "La colonne d'inclusion des sous-domaines n'est ni TRUE ni FALSE : {{value}}.",
|
||||
"netscapeSecureInvalid": "La colonne secure n'est ni TRUE ni FALSE : {{value}}.",
|
||||
"netscapeExpiryInvalid": "La colonne d'expiration n'est pas un nombre : {{value}}. La ligne a été écartée plutôt que transformée en cookie actif.",
|
||||
"nameValueNoPair": "Cette partie ne contient pas de paire nom=valeur et a été ignorée.",
|
||||
"pairTreatedAsAttribute": "« {{name}} » a été lu comme un attribut Set-Cookie et non comme un cookie, et sa valeur a été ignorée.",
|
||||
"unknown": "{{code}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
@@ -1086,7 +1220,9 @@
|
||||
"brandVersion": "Version de la marque",
|
||||
"proFeature": "Ceci est une fonctionnalité Pro",
|
||||
"generateFingerprint": "Générer l'empreinte",
|
||||
"refreshFingerprint": "Actualiser l'empreinte",
|
||||
"regenerateFingerprint": "Régénérer l'empreinte",
|
||||
"regenerateConfirmTitle": "Régénérer cette empreinte ?",
|
||||
"regenerateConfirmDescription": "Le profil conserve ses cookies et ses sessions, mais présentera un autre appareil. Les sites qui connaissent déjà ce profil peuvent vous demander de vous reconnecter, vous soumettre à une vérification ou bloquer le compte. Ne régénérez qu'un profil que vous n'avez pas encore utilisé ou que vous acceptez de perdre. Cette action est irréversible.",
|
||||
"canvasNoiseSeedPlaceholder": "Entrez une graine pour l'empreinte canvas",
|
||||
"addFontsPlaceholder": "Ajouter des polices...",
|
||||
"enterAsJson": "Entrez {{title}} en JSON"
|
||||
@@ -1888,6 +2024,10 @@
|
||||
"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.",
|
||||
"cookieImportBrowserRunning": "Impossible d'importer des cookies pendant que le navigateur est ouvert. Fermez-le et réessayez.",
|
||||
"cookieImportProfileProtected": "Impossible d'importer des cookies dans un profil protégé par mot de passe. Retirez d'abord le mot de passe.",
|
||||
"cookieImportRemoteSession": "Impossible d'importer des cookies tant qu'une session distante détient ce profil. Attendez la fin de la synchronisation.",
|
||||
"cookieImportNoCookies": "Aucun cookie n'a été trouvé dans ce que vous avez collé.",
|
||||
"selfHostedRequiresLogout": "Déconnectez-vous de votre compte Donut avant de configurer un serveur auto-hébergé.",
|
||||
"fingerprintRequiresPro": "Afficher ou modifier l'empreinte nécessite un forfait payant actif. La protection est incluse dans tous les forfaits.",
|
||||
"proxyNotWorking": "Le proxy sélectionné ne fonctionne pas, le profil n'a donc pas été créé.",
|
||||
@@ -1896,6 +2036,8 @@
|
||||
"camoufoxImportDeprecated": "L'importation de ce type de profil n'est plus prise en charge. Veuillez utiliser Wayfern à la place.",
|
||||
"updateChecksumsUnavailable": "La mise à jour {{version}} n'a pas pu être vérifiée car son fichier de sommes de contrôle n'a pas pu être récupéré. La mise à jour n'a pas été installée ; une nouvelle tentative aura lieu plus tard.",
|
||||
"updateChecksumMismatch": "Le fichier de mise à jour téléchargé {{file}} a échoué à la vérification de la somme de contrôle et a été supprimé. Veuillez réessayer.",
|
||||
"browserChecksumUnavailable": "{{browser}} {{version}} n'a pas pu être vérifié car son fichier de sommes de contrôle n'a pas pu être récupéré. Le téléchargement a été interrompu ; une nouvelle tentative aura lieu plus tard.",
|
||||
"browserChecksumMismatch": "L'archive {{browser}} {{version}} téléchargée a échoué à la vérification de la somme de contrôle et a été supprimée. Veuillez réessayer.",
|
||||
"nameCannotBeEmpty": "Le nom ne peut pas être vide",
|
||||
"wayfernVersionNotAvailable": "La version {{requested}} de Wayfern n'est pas disponible au téléchargement. La version actuelle est {{current}}.",
|
||||
"profileNameExists": "Un profil nommé « {{name}} » existe déjà",
|
||||
|
||||
+156
-14
@@ -134,7 +134,11 @@
|
||||
"title": "デフォルトブラウザ",
|
||||
"setAsDefault": "デフォルトブラウザに設定",
|
||||
"alreadyDefault": "既にデフォルトブラウザです",
|
||||
"description": "デフォルトに設定すると、Donut Browser がウェブリンクを処理し、使用するプロファイルを選択できます。"
|
||||
"description": "デフォルトに設定すると、Donut Browser がウェブリンクを処理し、使用するプロファイルを選択できます。",
|
||||
"setSuccess": "Donut Browser が既定のブラウザーになりました",
|
||||
"setFailed": "既定のブラウザーを設定できませんでした",
|
||||
"finishInSystemSettings": "Windows の設定で完了してください",
|
||||
"finishInSystemSettingsDescription": "Donut Browser を登録しました。Windows の設定が開いています。「Web ブラウザー」で Donut Browser を選ぶと完了します。"
|
||||
},
|
||||
"permissions": {
|
||||
"title": "システム権限",
|
||||
@@ -228,6 +232,49 @@
|
||||
"scrollGroupsLeft": "グループを左へスクロール",
|
||||
"scrollGroupsRight": "グループを右へスクロール"
|
||||
},
|
||||
"search": {
|
||||
"helpLabel": "検索構文",
|
||||
"helpTitle": "検索構文",
|
||||
"helpIntro": "単語を入力すると名前、メモ、タグ、ID を検索します。フィールドを加えるとさらに絞り込めます。",
|
||||
"fieldsTitle": "フィールド",
|
||||
"operatorsTitle": "演算子",
|
||||
"examplesTitle": "例",
|
||||
"fields": {
|
||||
"name": "プロファイル名",
|
||||
"tag": "タグ",
|
||||
"note": "メモ",
|
||||
"id": "プロファイル ID、先頭から一致",
|
||||
"group": "グループ名",
|
||||
"proxy": "プロキシ名",
|
||||
"vpn": "VPN 名",
|
||||
"ext": "拡張機能グループ名",
|
||||
"dns": "DNS ブロックリスト",
|
||||
"os": "オペレーティングシステム",
|
||||
"browser": "ブラウザ",
|
||||
"status": "実行中かどうか",
|
||||
"sync": "同期モード",
|
||||
"email": "所有者のメールアドレス",
|
||||
"version": "ブラウザのバージョン",
|
||||
"locked": "パスワード保護",
|
||||
"ephemeral": "一時プロファイル",
|
||||
"created": "作成日",
|
||||
"launched": "最終起動日"
|
||||
},
|
||||
"operators": {
|
||||
"negate": "一致するものを除外します",
|
||||
"quote": "スペースを含む値をひとまとまりにします",
|
||||
"or": "どちらかの条件に一致します",
|
||||
"comma": "どちらかの値に一致する短縮形",
|
||||
"exact": "一部ではなく値全体に一致します",
|
||||
"none": "ここには何も設定されていません。逆は any を使います",
|
||||
"compare": "日付とバージョンを比較します。7d、3w、6m は現在からさかのぼります"
|
||||
},
|
||||
"examples": {
|
||||
"a": "あるグループ内の実行中のプロファイル",
|
||||
"b": "タグがなくプロキシがあるプロファイル",
|
||||
"c": "30 日以上起動しておらず、アーカイブ済みを除いたもの"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"title": "プロファイル",
|
||||
"empty": "プロファイルがありません",
|
||||
@@ -237,6 +284,7 @@
|
||||
"noResultsDescription": "検索条件に一致するプロファイルがありません。",
|
||||
"table": {
|
||||
"name": "名前",
|
||||
"none": "なし",
|
||||
"browser": "ブラウザ",
|
||||
"status": "ステータス",
|
||||
"actions": "アクション",
|
||||
@@ -245,10 +293,8 @@
|
||||
"proxy": "プロキシ / VPN",
|
||||
"lastLaunch": "最終起動",
|
||||
"empty": "プロファイルが見つかりません。",
|
||||
"notSelected": "未選択",
|
||||
"ext": "拡張",
|
||||
"dns": "DNS",
|
||||
"extDefault": "既定",
|
||||
"dnsLevel": "DNS ブロックリスト: {{level}}",
|
||||
"extSearch": "グループを検索…",
|
||||
"extEmpty": "拡張機能グループがありません",
|
||||
@@ -262,7 +308,8 @@
|
||||
"emptyImport": "プロファイルをインポート",
|
||||
"emptyFilteredTitle": "プロファイルが見つかりません",
|
||||
"emptyFilteredHint": "このグループまたは検索に一致するプロファイルはありません。別のフィルターを試すか、新規作成してください。",
|
||||
"bot": "ボット"
|
||||
"bot": "ボット",
|
||||
"profileId": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "起動",
|
||||
@@ -636,6 +683,8 @@
|
||||
"serverError": "サーバーがエラーで応答しました",
|
||||
"connectFailed": "サーバーへの接続に失敗しました",
|
||||
"storageEndpoint": "ストレージ: {{endpoint}}",
|
||||
"storageUnreachableStatus": "ストレージに接続できません",
|
||||
"storageUnreachable": "サーバーには接続できますが、ストレージのアドレス {{endpoint}} にこのデバイスから接続できません。ファイル転送は失敗します。セルフホストの場合は、S3_PUBLIC_ENDPOINT にこのデバイスから接続できるアドレスを設定してください。",
|
||||
"settingsSaved": "同期設定を保存しました",
|
||||
"saveFailed": "設定の保存に失敗しました",
|
||||
"disconnected": "同期を切断しました",
|
||||
@@ -850,12 +899,7 @@
|
||||
"menuItem": "Cookie管理",
|
||||
"tabImport": "インポート",
|
||||
"tabExport": "エクスポート",
|
||||
"importDescription": "Netscape または JSON 形式のファイルから Cookie をインポートします。",
|
||||
"dropPrompt": "クリックして Cookie ファイルを選択",
|
||||
"fileFormats": "(.txt, .cookies, または .json)",
|
||||
"cookiesFound": "{{count}} 件の Cookie が見つかりました",
|
||||
"importedSuccess": "{{imported}} 件の Cookie をインポートしました ({{replaced}} 件置換)",
|
||||
"linesSkipped": "{{count}} 行をスキップ",
|
||||
"importDescription": "他のブラウザやツールからコピーした Cookie を貼り付けるか、ファイルを選んでください。",
|
||||
"fileReadError": "ファイルの読み込みに失敗しました",
|
||||
"loadFailed": "Cookie の読み込みに失敗しました: {{error}}",
|
||||
"cookiesLabel": "Cookies",
|
||||
@@ -864,9 +908,7 @@
|
||||
"deselectAll": "すべて解除",
|
||||
"noCookies": "このプロファイルに Cookie はありません",
|
||||
"doneButton": "完了",
|
||||
"importButton": "インポート",
|
||||
"exportButton": "エクスポート",
|
||||
"backButton": "戻る"
|
||||
"exportButton": "エクスポート"
|
||||
},
|
||||
"import": {
|
||||
"title": "Cookieのインポート",
|
||||
@@ -885,6 +927,98 @@
|
||||
"json": "JSON",
|
||||
"success": "Cookieのエクスポートに成功しました",
|
||||
"error": "Cookieのエクスポートに失敗しました"
|
||||
},
|
||||
"paste": {
|
||||
"label": "Cookie",
|
||||
"placeholder": "ここに Cookie を貼り付けてください。JSON(配列または {cookies: [...]} オブジェクト)、Netscape 形式の cookies.txt、または name=value; name2=value2",
|
||||
"chooseFile": "またはファイルを選択",
|
||||
"analyzing": "確認中…",
|
||||
"formatJson": "JSON",
|
||||
"formatNetscape": "Netscape",
|
||||
"formatNameValue": "Name=Value",
|
||||
"formatUnknown": "形式を認識できません",
|
||||
"siteLabel": "サイト",
|
||||
"sitePlaceholder": "example.com または https://example.com",
|
||||
"siteHelp": "name=value の一覧にはドメインが含まれないため、これらの Cookie が属するサイトを指定してください。",
|
||||
"scopeSubdomains": "{{domain}}:このドメインとすべてのサブドメイン",
|
||||
"scopeHostOnly": "{{domain}}:このホストのみ、サブドメインは含みません",
|
||||
"modeMerge": "マージ",
|
||||
"modeMergeDesc": "貼り付けた Cookie と一致する保存済み Cookie を更新し、残りを追加します。削除は行いません。",
|
||||
"modeReplace": "一致するサイトを置き換え",
|
||||
"modeReplaceDesc": "この貼り付けに含まれるサイトについて、ドット付きとドットなしの両方の形式でこのプロファイルの保存済み Cookie を削除してから、貼り付け内容を書き込みます。他のサイトの Cookie はすべて保持されます。",
|
||||
"replaceDeleteCount": "削除される保存済み Cookie:{{n}}",
|
||||
"unknownCount": "不明",
|
||||
"includeExpired": "期限切れの Cookie もインポートする",
|
||||
"expiredNote": "この貼り付け中の期限切れ:{{n}}",
|
||||
"clearsOnCloseWarning": "このプロファイルはブラウザを閉じると閲覧データを消去するため、これらの Cookie は次のセッション終了時に削除されます。",
|
||||
"previewTitle": "インポートする Cookie:{{n}}",
|
||||
"colSite": "サイト",
|
||||
"colName": "名前",
|
||||
"colPath": "パス",
|
||||
"colExpires": "有効期限",
|
||||
"colSecure": "Secure",
|
||||
"colHttpOnly": "HttpOnly",
|
||||
"colSameSite": "SameSite",
|
||||
"session": "セッション",
|
||||
"yes": "はい",
|
||||
"no": "いいえ",
|
||||
"sameSiteUnspecified": "未指定",
|
||||
"sameSiteNone": "None",
|
||||
"sameSiteLax": "Lax",
|
||||
"sameSiteStrict": "Strict",
|
||||
"issuesTitle": "問題",
|
||||
"showAll": "{{n}} 件すべてを表示",
|
||||
"showFewer": "表示を減らす",
|
||||
"sourceLine": "{{n}} 行目",
|
||||
"sourceCookie": "Cookie {{n}} 番目",
|
||||
"disabledEmpty": "上に Cookie を貼り付けるとインポートできます。",
|
||||
"disabledSite": "これらの Cookie が属するサイトを指定してください。",
|
||||
"disabledNoCookies": "この貼り付けから Cookie を読み取れませんでした。",
|
||||
"resultAdded": "追加",
|
||||
"resultOverwritten": "上書き",
|
||||
"resultDeleted": "削除",
|
||||
"resultSkipped": "スキップ",
|
||||
"issues": {
|
||||
"emptyInput": "まだ何も貼り付けられていません。",
|
||||
"siteInvalid": "「{{site}}」は使用できるサイトではないため無視されました。",
|
||||
"unrecognizedFormat": "これは JSON、Netscape 形式の cookies.txt、name=value の一覧のいずれでもありません。",
|
||||
"siteRequired": "name=value の一覧にはドメインが含まれません。これらの Cookie が属するサイトを指定してください。",
|
||||
"noCookiesFound": "この貼り付けから Cookie を読み取れませんでした。",
|
||||
"nameEmpty": "Cookie 名が空です。",
|
||||
"nameInvalid": "「{{name}}」は使用できる Cookie 名ではありません。",
|
||||
"nameMissing": "このエントリには名前がありません。",
|
||||
"valueInvalid": "「{{name}}」の値には Cookie が保持できない文字が含まれています。",
|
||||
"valueCoerced": "「{{name}}」の値はテキストではなかったため、テキストに変換されました。",
|
||||
"domainFromSite": "「{{name}}」にはドメインがなく、{{domain}} に紐づけられました。",
|
||||
"domainMissing": "「{{name}}」にはドメインがなく、サイトも指定されていません。",
|
||||
"domainInvalid": "「{{name}}」は使用できないドメインを指定しています:{{domain}}。",
|
||||
"domainAttributeIgnored": "Domain={{domain}} 属性は無視され、指定されたサイト {{site}} が使われました。",
|
||||
"hostOnlyMismatch": "「{{name}}」は hostOnly={{hostOnly}} ですが、ドメインは {{domain}} でした。フラグを適用しました。",
|
||||
"pathRepaired": "「{{name}}」のパスを {{path}} から修正しました。",
|
||||
"expiryMilliseconds": "「{{name}}」の有効期限({{expires}})はミリ秒単位だったため、秒に変換しました。",
|
||||
"expiryClamped": "有効期限が現実的でないほど先だったため、最大値に制限しました。",
|
||||
"expiryInvalid": "{{field}} は使用できるタイムスタンプではありません:{{value}}。",
|
||||
"expiresInvalid": "Expires は読み取れる日付ではありません:{{value}}。",
|
||||
"maxAgeInvalid": "Max-Age が数値ではありません:{{value}}。",
|
||||
"maxAgeDeletion": "「{{name}}」の Max-Age はこれを即座に削除します。",
|
||||
"sameSiteNoneInsecure": "{{domain}} の「{{name}}」は SameSite=None ですが Secure ではないため、ブラウザは送信を拒否します。",
|
||||
"sameSiteUnrecognized": "SameSite「{{value}}」を認識できず、未指定のままにしました。",
|
||||
"duplicateCookie": "{{domain}}{{path}} の「{{name}}」は貼り付けの後方にもあります。後のものが優先されます。",
|
||||
"boolCoercedFromString": "{{field}} は true や false ではなく文字列「{{value}}」だったため、真偽値として読み取りました。",
|
||||
"boolInvalid": "{{field}} は true でも false でもありません:{{value}}。",
|
||||
"quotedValue": "「{{name}}」の値を囲む引用符を削除しました。",
|
||||
"jsonParseFailed": "JSON を読み取れませんでした:{{message}}",
|
||||
"jsonNotCookieList": "この JSON は Cookie の配列でも、cookies 配列を持つオブジェクトでもありません。",
|
||||
"jsonEntryNotObject": "このエントリは JSON オブジェクトではありません。",
|
||||
"netscapePathOmitted": "この行にパス列がないため、/ を使いました。",
|
||||
"netscapeFieldCount": "この行は {{actual}} 列です。Netscape の Cookie 行は {{expected}} 列です。",
|
||||
"netscapeIncludeSubdomainsInvalid": "サブドメインを含む列が TRUE でも FALSE でもありません:{{value}}。",
|
||||
"netscapeSecureInvalid": "secure 列が TRUE でも FALSE でもありません:{{value}}。",
|
||||
"netscapeExpiryInvalid": "有効期限の列が数値ではありません:{{value}}。有効な Cookie にするのではなく、この行を破棄しました。",
|
||||
"nameValueNoPair": "この部分に name=value の組がないため無視されました。",
|
||||
"pairTreatedAsAttribute": "「{{name}}」は Cookie ではなく Set-Cookie の属性として読み取られ、その値は破棄されました。",
|
||||
"unknown": "{{code}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
@@ -1083,7 +1217,9 @@
|
||||
"brandVersion": "ブランドバージョン",
|
||||
"proFeature": "これはPro機能です",
|
||||
"generateFingerprint": "フィンガープリントを生成",
|
||||
"refreshFingerprint": "フィンガープリントを更新",
|
||||
"regenerateFingerprint": "フィンガープリントを再生成",
|
||||
"regenerateConfirmTitle": "このフィンガープリントを再生成しますか?",
|
||||
"regenerateConfirmDescription": "Cookie とログイン状態は保持されますが、プロファイルは別のデバイスとして認識されます。すでにこのプロファイルを知っているサイトでは、再ログインを求められたり、追加の確認を要求されたり、アカウントがブロックされたりする場合があります。再生成するのは、まだ使用していないプロファイル、または失っても問題ないプロファイルだけにしてください。この操作は取り消せません。",
|
||||
"canvasNoiseSeedPlaceholder": "キャンバスフィンガープリント用のシード文字列を入力",
|
||||
"addFontsPlaceholder": "フォントを追加...",
|
||||
"enterAsJson": "{{title}} を JSON で入力"
|
||||
@@ -1881,6 +2017,10 @@
|
||||
"invalidLaunchHookUrl": "起動フックURLが無効です。完全な http:// または https:// URL を使用してください。",
|
||||
"cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。",
|
||||
"cookieDbUnavailable": "Cookie を読み取れません — Cookie ストアを利用できません。",
|
||||
"cookieImportBrowserRunning": "ブラウザの実行中は Cookie をインポートできません。ブラウザを閉じてから再試行してください。",
|
||||
"cookieImportProfileProtected": "パスワード保護されたプロファイルには Cookie をインポートできません。先にパスワードを解除してください。",
|
||||
"cookieImportRemoteSession": "リモートセッションがこのプロファイルを使用している間は Cookie をインポートできません。同期の完了をお待ちください。",
|
||||
"cookieImportNoCookies": "貼り付けた内容から Cookie が見つかりませんでした。",
|
||||
"selfHostedRequiresLogout": "セルフホストサーバーを設定する前に Donut アカウントからサインアウトしてください。",
|
||||
"fingerprintRequiresPro": "フィンガープリントの表示または編集には有効な有料プランが必要です。保護機能はすべてのプランに含まれています。",
|
||||
"proxyNotWorking": "選択したプロキシが機能していないため、プロファイルは作成されませんでした。",
|
||||
@@ -1889,6 +2029,8 @@
|
||||
"camoufoxImportDeprecated": "このタイプのプロファイルのインポートはサポートされなくなりました。代わりにWayfernを使用してください。",
|
||||
"updateChecksumsUnavailable": "アップデート {{version}} のチェックサムファイルを取得できなかったため、検証できませんでした。アップデートはインストールされませんでした。後で再試行されます。",
|
||||
"updateChecksumMismatch": "ダウンロードしたアップデートファイル {{file}} はチェックサム検証に失敗したため破棄されました。もう一度お試しください。",
|
||||
"browserChecksumUnavailable": "{{browser}} {{version}} のチェックサムファイルを取得できなかったため、検証できませんでした。ダウンロードは中止されました。後で再試行されます。",
|
||||
"browserChecksumMismatch": "ダウンロードした {{browser}} {{version}} のアーカイブはチェックサム検証に失敗したため破棄されました。もう一度お試しください。",
|
||||
"nameCannotBeEmpty": "名前を空にすることはできません",
|
||||
"wayfernVersionNotAvailable": "Wayfernのバージョン{{requested}}はダウンロードできません。現在のバージョンは{{current}}です。",
|
||||
"profileNameExists": "「{{name}}」という名前のプロファイルは既に存在します",
|
||||
|
||||
+156
-14
@@ -134,7 +134,11 @@
|
||||
"title": "기본 브라우저",
|
||||
"setAsDefault": "기본 브라우저로 설정",
|
||||
"alreadyDefault": "이미 기본 브라우저입니다",
|
||||
"description": "기본 브라우저로 설정하면 Donut Browser가 웹 링크를 처리하고 사용할 프로필을 선택할 수 있습니다."
|
||||
"description": "기본 브라우저로 설정하면 Donut Browser가 웹 링크를 처리하고 사용할 프로필을 선택할 수 있습니다.",
|
||||
"setSuccess": "이제 Donut Browser가 기본 브라우저입니다",
|
||||
"setFailed": "기본 브라우저를 설정하지 못했습니다",
|
||||
"finishInSystemSettings": "Windows 설정에서 완료하세요",
|
||||
"finishInSystemSettingsDescription": "Donut Browser가 등록되었습니다. Windows 설정이 열려 있습니다. '웹 브라우저'에서 Donut Browser를 선택하면 완료됩니다."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "시스템 권한",
|
||||
@@ -228,6 +232,49 @@
|
||||
"scrollGroupsLeft": "그룹 왼쪽으로 스크롤",
|
||||
"scrollGroupsRight": "그룹 오른쪽으로 스크롤"
|
||||
},
|
||||
"search": {
|
||||
"helpLabel": "검색 구문",
|
||||
"helpTitle": "검색 구문",
|
||||
"helpIntro": "단어를 입력하면 이름, 메모, 태그, ID를 검색합니다. 필드를 추가하면 더 좁힐 수 있습니다.",
|
||||
"fieldsTitle": "필드",
|
||||
"operatorsTitle": "연산자",
|
||||
"examplesTitle": "예시",
|
||||
"fields": {
|
||||
"name": "프로필 이름",
|
||||
"tag": "태그",
|
||||
"note": "메모",
|
||||
"id": "프로필 ID, 앞부분부터 일치",
|
||||
"group": "그룹 이름",
|
||||
"proxy": "프록시 이름",
|
||||
"vpn": "VPN 이름",
|
||||
"ext": "확장 프로그램 그룹 이름",
|
||||
"dns": "DNS 차단 목록",
|
||||
"os": "운영 체제",
|
||||
"browser": "브라우저",
|
||||
"status": "실행 중 여부",
|
||||
"sync": "동기화 모드",
|
||||
"email": "소유자 이메일",
|
||||
"version": "브라우저 버전",
|
||||
"locked": "비밀번호 보호",
|
||||
"ephemeral": "임시 프로필",
|
||||
"created": "생성 날짜",
|
||||
"launched": "마지막 실행 날짜"
|
||||
},
|
||||
"operators": {
|
||||
"negate": "일치하는 항목을 제외합니다",
|
||||
"quote": "공백이 있는 값을 하나로 묶습니다",
|
||||
"or": "둘 중 하나와 일치합니다",
|
||||
"comma": "둘 중 하나의 값을 뜻하는 축약형",
|
||||
"exact": "일부가 아니라 값 전체와 일치합니다",
|
||||
"none": "여기에는 아무것도 설정되지 않았습니다. 반대는 any를 사용합니다",
|
||||
"compare": "날짜와 버전을 비교합니다. 7d, 3w, 6m은 현재부터 거슬러 셉니다"
|
||||
},
|
||||
"examples": {
|
||||
"a": "한 그룹에서 실행 중인 프로필",
|
||||
"b": "태그가 없고 프록시가 있는 프로필",
|
||||
"c": "30일 넘게 실행하지 않은 프로필, 보관된 것은 제외"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"title": "프로필",
|
||||
"empty": "아직 프로필이 없습니다",
|
||||
@@ -237,6 +284,7 @@
|
||||
"noResultsDescription": "검색 조건과 일치하는 프로필이 없습니다.",
|
||||
"table": {
|
||||
"name": "이름",
|
||||
"none": "없음",
|
||||
"browser": "브라우저",
|
||||
"status": "상태",
|
||||
"actions": "작업",
|
||||
@@ -245,10 +293,8 @@
|
||||
"proxy": "프록시 / VPN",
|
||||
"lastLaunch": "마지막 실행",
|
||||
"empty": "프로필을 찾을 수 없습니다.",
|
||||
"notSelected": "선택 안 됨",
|
||||
"ext": "확장",
|
||||
"dns": "DNS",
|
||||
"extDefault": "기본값",
|
||||
"dnsLevel": "DNS 차단 목록: {{level}}",
|
||||
"extSearch": "그룹 검색…",
|
||||
"extEmpty": "확장 프로그램 그룹이 없습니다",
|
||||
@@ -262,7 +308,8 @@
|
||||
"emptyImport": "프로필 가져오기",
|
||||
"emptyFilteredTitle": "프로필을 찾을 수 없습니다",
|
||||
"emptyFilteredHint": "이 그룹 또는 검색과 일치하는 프로필이 없습니다. 다른 필터를 사용하거나 새로 만드세요.",
|
||||
"bot": "봇"
|
||||
"bot": "봇",
|
||||
"profileId": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "실행",
|
||||
@@ -636,6 +683,8 @@
|
||||
"serverError": "서버가 오류로 응답했습니다",
|
||||
"connectFailed": "서버에 연결하지 못했습니다",
|
||||
"storageEndpoint": "스토리지: {{endpoint}}",
|
||||
"storageUnreachableStatus": "스토리지에 연결할 수 없음",
|
||||
"storageUnreachable": "서버에는 연결되지만 스토리지 주소 {{endpoint}}에 이 기기에서 연결할 수 없습니다. 파일 전송이 실패합니다. 자체 호스팅 중이라면 S3_PUBLIC_ENDPOINT를 이 기기에서 연결할 수 있는 주소로 설정하세요.",
|
||||
"settingsSaved": "동기화 설정이 저장되었습니다",
|
||||
"saveFailed": "설정 저장 실패",
|
||||
"disconnected": "동기화 연결 끊김",
|
||||
@@ -850,12 +899,7 @@
|
||||
"menuItem": "쿠키 관리",
|
||||
"tabImport": "가져오기",
|
||||
"tabExport": "내보내기",
|
||||
"importDescription": "Netscape 또는 JSON 형식 파일에서 쿠키를 가져옵니다.",
|
||||
"dropPrompt": "쿠키 파일을 선택하려면 클릭하세요",
|
||||
"fileFormats": "(.txt, .cookies 또는 .json)",
|
||||
"cookiesFound": "{{count}}개 쿠키 발견",
|
||||
"importedSuccess": "{{imported}}개 쿠키를 가져왔습니다 ({{replaced}}개 교체됨)",
|
||||
"linesSkipped": "{{count}}개 줄 건너뜀",
|
||||
"importDescription": "다른 브라우저나 도구에서 복사한 쿠키를 붙여넣거나 파일을 선택하세요.",
|
||||
"fileReadError": "파일 읽기 실패",
|
||||
"loadFailed": "쿠키 불러오기 실패: {{error}}",
|
||||
"cookiesLabel": "쿠키",
|
||||
@@ -864,9 +908,7 @@
|
||||
"deselectAll": "모두 선택 해제",
|
||||
"noCookies": "이 프로필에 쿠키가 없습니다",
|
||||
"doneButton": "완료",
|
||||
"importButton": "가져오기",
|
||||
"exportButton": "내보내기",
|
||||
"backButton": "뒤로"
|
||||
"exportButton": "내보내기"
|
||||
},
|
||||
"import": {
|
||||
"title": "쿠키 가져오기",
|
||||
@@ -885,6 +927,98 @@
|
||||
"json": "JSON",
|
||||
"success": "쿠키를 내보냈습니다",
|
||||
"error": "쿠키 내보내기 실패"
|
||||
},
|
||||
"paste": {
|
||||
"label": "쿠키",
|
||||
"placeholder": "여기에 쿠키를 붙여넣으세요. JSON(배열 또는 {cookies: [...]} 객체), Netscape cookies.txt, 또는 name=value; name2=value2",
|
||||
"chooseFile": "또는 파일 선택",
|
||||
"analyzing": "확인 중…",
|
||||
"formatJson": "JSON",
|
||||
"formatNetscape": "Netscape",
|
||||
"formatNameValue": "Name=Value",
|
||||
"formatUnknown": "형식을 인식할 수 없음",
|
||||
"siteLabel": "사이트",
|
||||
"sitePlaceholder": "example.com 또는 https://example.com",
|
||||
"siteHelp": "name=value 목록에는 도메인이 없으므로 이 쿠키가 속한 사이트를 지정하세요.",
|
||||
"scopeSubdomains": "{{domain}}: 이 도메인과 모든 하위 도메인",
|
||||
"scopeHostOnly": "{{domain}}: 이 호스트만, 하위 도메인 제외",
|
||||
"modeMerge": "병합",
|
||||
"modeMergeDesc": "붙여넣은 쿠키와 일치하는 저장된 쿠키를 갱신하고 나머지는 추가하며, 아무것도 삭제하지 않습니다.",
|
||||
"modeReplace": "일치하는 사이트 교체",
|
||||
"modeReplaceDesc": "이번 붙여넣기에 포함된 사이트에 대해 점이 있는 형태와 없는 형태 모두로 이 프로필의 저장된 쿠키를 삭제한 뒤 붙여넣은 내용을 기록합니다. 다른 모든 사이트의 쿠키는 유지됩니다.",
|
||||
"replaceDeleteCount": "삭제될 저장된 쿠키: {{n}}",
|
||||
"unknownCount": "알 수 없음",
|
||||
"includeExpired": "이미 만료된 쿠키도 가져오기",
|
||||
"expiredNote": "이번 붙여넣기에서 만료됨: {{n}}",
|
||||
"clearsOnCloseWarning": "이 프로필은 브라우저를 닫을 때 인터넷 사용 기록을 지우므로, 이 쿠키들은 다음 세션이 끝나면 삭제됩니다.",
|
||||
"previewTitle": "가져올 쿠키: {{n}}",
|
||||
"colSite": "사이트",
|
||||
"colName": "이름",
|
||||
"colPath": "경로",
|
||||
"colExpires": "만료",
|
||||
"colSecure": "Secure",
|
||||
"colHttpOnly": "HttpOnly",
|
||||
"colSameSite": "SameSite",
|
||||
"session": "세션",
|
||||
"yes": "예",
|
||||
"no": "아니오",
|
||||
"sameSiteUnspecified": "지정 안 함",
|
||||
"sameSiteNone": "None",
|
||||
"sameSiteLax": "Lax",
|
||||
"sameSiteStrict": "Strict",
|
||||
"issuesTitle": "문제",
|
||||
"showAll": "{{n}}개 모두 보기",
|
||||
"showFewer": "접기",
|
||||
"sourceLine": "{{n}}번째 줄",
|
||||
"sourceCookie": "{{n}}번째 쿠키",
|
||||
"disabledEmpty": "위에 쿠키를 붙여넣으면 가져올 수 있습니다.",
|
||||
"disabledSite": "이 쿠키가 속한 사이트를 지정하세요.",
|
||||
"disabledNoCookies": "이번 붙여넣기에서 쿠키를 읽을 수 없었습니다.",
|
||||
"resultAdded": "추가됨",
|
||||
"resultOverwritten": "덮어쓰기됨",
|
||||
"resultDeleted": "삭제됨",
|
||||
"resultSkipped": "건너뜀",
|
||||
"issues": {
|
||||
"emptyInput": "아직 붙여넣은 내용이 없습니다.",
|
||||
"siteInvalid": "\"{{site}}\"은(는) 사용할 수 없는 사이트라 무시되었습니다.",
|
||||
"unrecognizedFormat": "이것은 JSON도, Netscape cookies.txt도, name=value 목록도 아닙니다.",
|
||||
"siteRequired": "name=value 목록에는 도메인이 없습니다. 이 쿠키가 속한 사이트를 지정하세요.",
|
||||
"noCookiesFound": "이번 붙여넣기에서 쿠키를 읽을 수 없었습니다.",
|
||||
"nameEmpty": "쿠키 이름이 비어 있습니다.",
|
||||
"nameInvalid": "\"{{name}}\"은(는) 사용할 수 없는 쿠키 이름입니다.",
|
||||
"nameMissing": "이 항목에는 이름이 없습니다.",
|
||||
"valueInvalid": "\"{{name}}\"의 값에 쿠키가 담을 수 없는 문자가 있습니다.",
|
||||
"valueCoerced": "\"{{name}}\"의 값이 텍스트가 아니어서 텍스트로 변환했습니다.",
|
||||
"domainFromSite": "\"{{name}}\"에 도메인이 없어 {{domain}}에 연결했습니다.",
|
||||
"domainMissing": "\"{{name}}\"에 도메인이 없고 사이트도 지정되지 않았습니다.",
|
||||
"domainInvalid": "\"{{name}}\"이(가) 사용할 수 없는 도메인을 가리킵니다: {{domain}}.",
|
||||
"domainAttributeIgnored": "Domain={{domain}} 속성을 무시하고 지정한 사이트 {{site}}을(를) 사용했습니다.",
|
||||
"hostOnlyMismatch": "\"{{name}}\"은(는) hostOnly={{hostOnly}}로 되어 있지만 도메인은 {{domain}}이었습니다. 플래그를 적용했습니다.",
|
||||
"pathRepaired": "\"{{name}}\"의 경로를 {{path}}에서 보정했습니다.",
|
||||
"expiryMilliseconds": "\"{{name}}\"의 만료 시각({{expires}})이 밀리초 단위여서 초 단위로 변환했습니다.",
|
||||
"expiryClamped": "만료 시각이 현실적이지 않을 만큼 멀어서 최대값으로 제한했습니다.",
|
||||
"expiryInvalid": "{{field}}은(는) 사용할 수 있는 타임스탬프가 아닙니다: {{value}}.",
|
||||
"expiresInvalid": "Expires는 읽을 수 있는 날짜가 아닙니다: {{value}}.",
|
||||
"maxAgeInvalid": "Max-Age가 숫자가 아닙니다: {{value}}.",
|
||||
"maxAgeDeletion": "\"{{name}}\"의 Max-Age가 이를 즉시 삭제합니다.",
|
||||
"sameSiteNoneInsecure": "{{domain}}의 \"{{name}}\"은(는) SameSite=None이지만 Secure가 아니므로 브라우저가 전송을 거부합니다.",
|
||||
"sameSiteUnrecognized": "SameSite \"{{value}}\"을(를) 인식하지 못해 지정하지 않은 상태로 두었습니다.",
|
||||
"duplicateCookie": "{{domain}}{{path}}의 \"{{name}}\"이(가) 붙여넣기 뒷부분에 다시 나타납니다. 나중 것이 적용됩니다.",
|
||||
"boolCoercedFromString": "{{field}}이(가) true나 false가 아닌 텍스트 \"{{value}}\"였으므로 불리언으로 읽었습니다.",
|
||||
"boolInvalid": "{{field}}은(는) true도 false도 아닙니다: {{value}}.",
|
||||
"quotedValue": "\"{{name}}\" 값을 감싼 따옴표를 제거했습니다.",
|
||||
"jsonParseFailed": "JSON을 읽을 수 없었습니다: {{message}}",
|
||||
"jsonNotCookieList": "이 JSON은 쿠키 배열도, cookies 배열을 담은 객체도 아닙니다.",
|
||||
"jsonEntryNotObject": "이 항목은 JSON 객체가 아닙니다.",
|
||||
"netscapePathOmitted": "이 줄에 경로 열이 없어 /를 사용했습니다.",
|
||||
"netscapeFieldCount": "이 줄은 {{actual}}개 열입니다. Netscape 쿠키 줄은 {{expected}}개입니다.",
|
||||
"netscapeIncludeSubdomainsInvalid": "하위 도메인 포함 열이 TRUE도 FALSE도 아닙니다: {{value}}.",
|
||||
"netscapeSecureInvalid": "secure 열이 TRUE도 FALSE도 아닙니다: {{value}}.",
|
||||
"netscapeExpiryInvalid": "만료 열이 숫자가 아닙니다: {{value}}. 이 줄을 유효한 쿠키로 만드는 대신 버렸습니다.",
|
||||
"nameValueNoPair": "이 부분에는 name=value 쌍이 없어 무시되었습니다.",
|
||||
"pairTreatedAsAttribute": "\"{{name}}\"을(를) 쿠키가 아니라 Set-Cookie 속성으로 읽었으며, 그 값은 버렸습니다.",
|
||||
"unknown": "{{code}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
@@ -1083,7 +1217,9 @@
|
||||
"brandVersion": "브랜드 버전",
|
||||
"proFeature": "이것은 Pro 기능입니다",
|
||||
"generateFingerprint": "핑거프린트 생성",
|
||||
"refreshFingerprint": "핑거프린트 새로 고침",
|
||||
"regenerateFingerprint": "핑거프린트 재생성",
|
||||
"regenerateConfirmTitle": "이 핑거프린트를 재생성할까요?",
|
||||
"regenerateConfirmDescription": "쿠키와 로그인 상태는 유지되지만 프로필은 다른 기기로 표시됩니다. 이미 이 프로필을 알고 있는 사이트에서는 다시 로그인을 요구하거나 추가 인증을 요청하거나 계정을 차단할 수 있습니다. 아직 사용하지 않았거나 잃어도 괜찮은 프로필만 재생성하세요. 이 작업은 되돌릴 수 없습니다.",
|
||||
"canvasNoiseSeedPlaceholder": "캔버스 핑거프린트의 시드 문자열 입력",
|
||||
"addFontsPlaceholder": "글꼴 추가...",
|
||||
"enterAsJson": "{{title}}을(를) JSON으로 입력"
|
||||
@@ -1881,6 +2017,10 @@
|
||||
"invalidLaunchHookUrl": "잘못된 실행 후크 URL입니다. 전체 http:// 또는 https:// URL을 사용하세요.",
|
||||
"cookieDbLocked": "쿠키를 읽을 수 없습니다 — 데이터베이스가 잠겨 있습니다. 브라우저를 닫고 다시 시도하세요.",
|
||||
"cookieDbUnavailable": "쿠키를 읽을 수 없습니다 — 쿠키 저장소를 사용할 수 없습니다.",
|
||||
"cookieImportBrowserRunning": "브라우저가 실행 중일 때는 쿠키를 가져올 수 없습니다. 브라우저를 닫고 다시 시도하세요.",
|
||||
"cookieImportProfileProtected": "비밀번호로 보호된 프로필에는 쿠키를 가져올 수 없습니다. 먼저 비밀번호를 해제하세요.",
|
||||
"cookieImportRemoteSession": "원격 세션이 이 프로필을 사용하는 동안에는 쿠키를 가져올 수 없습니다. 동기화가 끝날 때까지 기다리세요.",
|
||||
"cookieImportNoCookies": "붙여넣은 내용에서 쿠키를 찾지 못했습니다.",
|
||||
"selfHostedRequiresLogout": "자체 호스팅 서버를 구성하기 전에 Donut 계정에서 로그아웃하세요.",
|
||||
"fingerprintRequiresPro": "핑거프린트를 보거나 편집하려면 활성 유료 요금제가 필요합니다. 보호 기능은 모든 요금제에 포함되어 있습니다.",
|
||||
"proxyNotWorking": "선택한 프록시가 작동하지 않아 프로필이 생성되지 않았습니다.",
|
||||
@@ -1889,6 +2029,8 @@
|
||||
"camoufoxImportDeprecated": "이 유형의 프로필 가져오기는 더 이상 지원되지 않습니다. 대신 Wayfern을 사용하세요.",
|
||||
"updateChecksumsUnavailable": "업데이트 {{version}}의 체크섬 파일을 가져올 수 없어 검증하지 못했습니다. 업데이트가 설치되지 않았으며 나중에 다시 시도됩니다.",
|
||||
"updateChecksumMismatch": "다운로드한 업데이트 파일 {{file}}이(가) 체크섬 검증에 실패하여 삭제되었습니다. 다시 시도해 주세요.",
|
||||
"browserChecksumUnavailable": "{{browser}} {{version}}의 체크섬 파일을 가져올 수 없어 검증하지 못했습니다. 다운로드가 중단되었으며 나중에 다시 시도됩니다.",
|
||||
"browserChecksumMismatch": "다운로드한 {{browser}} {{version}} 아카이브가 체크섬 검증에 실패하여 삭제되었습니다. 다시 시도해 주세요.",
|
||||
"nameCannotBeEmpty": "이름은 비워둘 수 없습니다",
|
||||
"wayfernVersionNotAvailable": "Wayfern 버전 {{requested}}은(는) 다운로드할 수 없습니다. 현재 버전은 {{current}}입니다.",
|
||||
"profileNameExists": "\"{{name}}\" 이름의 프로필이 이미 있습니다",
|
||||
|
||||
+156
-14
@@ -134,7 +134,11 @@
|
||||
"title": "Navegador Padrão",
|
||||
"setAsDefault": "Definir como Navegador Padrão",
|
||||
"alreadyDefault": "Já é o Navegador Padrão",
|
||||
"description": "Quando definido como padrão, o Donut Browser lidará com links da web e permitirá que você escolha qual perfil usar."
|
||||
"description": "Quando definido como padrão, o Donut Browser lidará com links da web e permitirá que você escolha qual perfil usar.",
|
||||
"setSuccess": "O Donut Browser agora é o seu navegador padrão",
|
||||
"setFailed": "Não foi possível definir o navegador padrão",
|
||||
"finishInSystemSettings": "Conclua nas Configurações do Windows",
|
||||
"finishInSystemSettingsDescription": "O Donut Browser está registrado. As Configurações do Windows foram abertas: escolha o Donut Browser em Navegador da web para concluir."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Permissões do Sistema",
|
||||
@@ -228,6 +232,49 @@
|
||||
"scrollGroupsLeft": "Rolar grupos para a esquerda",
|
||||
"scrollGroupsRight": "Rolar grupos para a direita"
|
||||
},
|
||||
"search": {
|
||||
"helpLabel": "Sintaxe de pesquisa",
|
||||
"helpTitle": "Sintaxe de pesquisa",
|
||||
"helpIntro": "Digite palavras para pesquisar em nomes, notas, etiquetas e ids. Adicione campos para restringir mais.",
|
||||
"fieldsTitle": "Campos",
|
||||
"operatorsTitle": "Operadores",
|
||||
"examplesTitle": "Exemplos",
|
||||
"fields": {
|
||||
"name": "Nome do perfil",
|
||||
"tag": "Etiqueta",
|
||||
"note": "Nota",
|
||||
"id": "Id do perfil, a partir do início",
|
||||
"group": "Nome do grupo",
|
||||
"proxy": "Nome do proxy",
|
||||
"vpn": "Nome da VPN",
|
||||
"ext": "Nome do grupo de extensões",
|
||||
"dns": "Lista de bloqueio DNS",
|
||||
"os": "Sistema operacional",
|
||||
"browser": "Navegador",
|
||||
"status": "Em execução ou não",
|
||||
"sync": "Modo de sincronização",
|
||||
"email": "E-mail do proprietário",
|
||||
"version": "Versão do navegador",
|
||||
"locked": "Protegido por senha",
|
||||
"ephemeral": "Perfil efêmero",
|
||||
"created": "Data de criação",
|
||||
"launched": "Data da última execução"
|
||||
},
|
||||
"operators": {
|
||||
"negate": "Exclui o que corresponde",
|
||||
"quote": "Mantém junto um valor com espaços",
|
||||
"or": "Corresponde a qualquer um dos termos",
|
||||
"comma": "Atalho para qualquer um dos valores",
|
||||
"exact": "Corresponde ao valor inteiro, não a uma parte",
|
||||
"none": "Nada definido aqui; use any para o contrário",
|
||||
"compare": "Compara datas e versões; 7d, 3w e 6m contam para trás a partir de agora"
|
||||
},
|
||||
"examples": {
|
||||
"a": "Perfis em execução em um grupo",
|
||||
"b": "Perfis sem etiquetas que têm proxy",
|
||||
"c": "Sem execução há mais de 30 dias, ignorando os arquivados"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"title": "Perfis",
|
||||
"empty": "Nenhum perfil ainda",
|
||||
@@ -237,6 +284,7 @@
|
||||
"noResultsDescription": "Nenhum perfil corresponde aos seus critérios de pesquisa.",
|
||||
"table": {
|
||||
"name": "Nome",
|
||||
"none": "Nenhum",
|
||||
"browser": "Navegador",
|
||||
"status": "Status",
|
||||
"actions": "Ações",
|
||||
@@ -245,10 +293,8 @@
|
||||
"proxy": "Proxy / VPN",
|
||||
"lastLaunch": "Último Início",
|
||||
"empty": "Nenhum perfil encontrado.",
|
||||
"notSelected": "Não selecionado",
|
||||
"ext": "EXT",
|
||||
"dns": "DNS",
|
||||
"extDefault": "Padrão",
|
||||
"dnsLevel": "Lista DNS: {{level}}",
|
||||
"extSearch": "Pesquisar grupos…",
|
||||
"extEmpty": "Sem grupos de extensões",
|
||||
@@ -262,7 +308,8 @@
|
||||
"emptyImport": "Importar perfis",
|
||||
"emptyFilteredTitle": "Nenhum perfil encontrado",
|
||||
"emptyFilteredHint": "Nenhum perfil corresponde a este grupo ou pesquisa. Tente outro filtro ou crie um novo.",
|
||||
"bot": "Bot"
|
||||
"bot": "Bot",
|
||||
"profileId": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Iniciar",
|
||||
@@ -637,6 +684,8 @@
|
||||
"serverError": "O servidor respondeu com um erro",
|
||||
"connectFailed": "Falha ao conectar ao servidor",
|
||||
"storageEndpoint": "Armazenamento: {{endpoint}}",
|
||||
"storageUnreachableStatus": "Armazenamento inacessível",
|
||||
"storageUnreachable": "O servidor está acessível, mas o endereço de armazenamento {{endpoint}} não pode ser acessado deste dispositivo. As transferências de arquivos vão falhar. Se você hospeda o servidor, defina S3_PUBLIC_ENDPOINT com um endereço acessível deste dispositivo.",
|
||||
"settingsSaved": "Configurações de sincronização salvas",
|
||||
"saveFailed": "Falha ao salvar as configurações",
|
||||
"disconnected": "Sincronização desconectada",
|
||||
@@ -853,12 +902,7 @@
|
||||
"menuItem": "Gerenciamento de Cookies",
|
||||
"tabImport": "Importar",
|
||||
"tabExport": "Exportar",
|
||||
"importDescription": "Importe cookies de um arquivo no formato Netscape ou JSON.",
|
||||
"dropPrompt": "Clique para escolher um arquivo de cookies",
|
||||
"fileFormats": "(.txt, .cookies ou .json)",
|
||||
"cookiesFound": "{{count}} cookies encontrados",
|
||||
"importedSuccess": "{{imported}} cookies importados com sucesso ({{replaced}} substituídos)",
|
||||
"linesSkipped": "{{count}} linha(s) ignoradas",
|
||||
"importDescription": "Cole os cookies copiados de outro navegador ou ferramenta, ou escolha um arquivo.",
|
||||
"fileReadError": "Falha ao ler o arquivo",
|
||||
"loadFailed": "Falha ao carregar cookies: {{error}}",
|
||||
"cookiesLabel": "Cookies",
|
||||
@@ -867,9 +911,7 @@
|
||||
"deselectAll": "Desmarcar tudo",
|
||||
"noCookies": "Nenhum cookie encontrado neste perfil",
|
||||
"doneButton": "Concluído",
|
||||
"importButton": "Importar",
|
||||
"exportButton": "Exportar",
|
||||
"backButton": "Voltar"
|
||||
"exportButton": "Exportar"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importar Cookies",
|
||||
@@ -888,6 +930,98 @@
|
||||
"json": "JSON",
|
||||
"success": "Cookies exportados com sucesso",
|
||||
"error": "Falha ao exportar cookies"
|
||||
},
|
||||
"paste": {
|
||||
"label": "Cookies",
|
||||
"placeholder": "Cole os cookies aqui. JSON (uma matriz ou um objeto {cookies: [...]}), um cookies.txt do Netscape, ou nome=valor; nome2=valor2",
|
||||
"chooseFile": "ou escolha um arquivo",
|
||||
"analyzing": "Verificando…",
|
||||
"formatJson": "JSON",
|
||||
"formatNetscape": "Netscape",
|
||||
"formatNameValue": "Nome=Valor",
|
||||
"formatUnknown": "Formato não reconhecido",
|
||||
"siteLabel": "Site",
|
||||
"sitePlaceholder": "exemplo.com ou https://exemplo.com",
|
||||
"siteHelp": "Uma lista nome=valor não carrega domínio próprio, então informe o site ao qual esses cookies pertencem.",
|
||||
"scopeSubdomains": "{{domain}}: este domínio e todos os seus subdomínios",
|
||||
"scopeHostOnly": "{{domain}}: apenas este host exato, sem subdomínios",
|
||||
"modeMerge": "Mesclar",
|
||||
"modeMergeDesc": "Atualiza os cookies armazenados que um cookie colado corresponde, adiciona os demais e não exclui nada.",
|
||||
"modeReplace": "Substituir os sites correspondentes",
|
||||
"modeReplaceDesc": "Exclui os cookies armazenados deste perfil para os sites citados nesta colagem, tanto na forma com ponto quanto sem ponto, e depois grava a colagem. Os cookies de todos os outros sites são mantidos.",
|
||||
"replaceDeleteCount": "Cookies armazenados que seriam excluídos: {{n}}",
|
||||
"unknownCount": "desconhecido",
|
||||
"includeExpired": "Importar também os cookies já expirados",
|
||||
"expiredNote": "Já expirados nesta colagem: {{n}}",
|
||||
"clearsOnCloseWarning": "Este perfil apaga os dados de navegação quando o navegador fecha, então esses cookies serão excluídos ao final da próxima sessão.",
|
||||
"previewTitle": "Cookies a importar: {{n}}",
|
||||
"colSite": "Site",
|
||||
"colName": "Nome",
|
||||
"colPath": "Caminho",
|
||||
"colExpires": "Expira",
|
||||
"colSecure": "Secure",
|
||||
"colHttpOnly": "HttpOnly",
|
||||
"colSameSite": "SameSite",
|
||||
"session": "Sessão",
|
||||
"yes": "Sim",
|
||||
"no": "Não",
|
||||
"sameSiteUnspecified": "Não especificado",
|
||||
"sameSiteNone": "None",
|
||||
"sameSiteLax": "Lax",
|
||||
"sameSiteStrict": "Strict",
|
||||
"issuesTitle": "Ocorrências",
|
||||
"showAll": "Mostrar todas as {{n}}",
|
||||
"showFewer": "Mostrar menos",
|
||||
"sourceLine": "Linha {{n}}",
|
||||
"sourceCookie": "Cookie {{n}}",
|
||||
"disabledEmpty": "Cole cookies acima para importá-los.",
|
||||
"disabledSite": "Informe o site ao qual esses cookies pertencem.",
|
||||
"disabledNoCookies": "Nenhum cookie pôde ser lido desta colagem.",
|
||||
"resultAdded": "Adicionados",
|
||||
"resultOverwritten": "Sobrescritos",
|
||||
"resultDeleted": "Excluídos",
|
||||
"resultSkipped": "Ignorados",
|
||||
"issues": {
|
||||
"emptyInput": "Nada foi colado ainda.",
|
||||
"siteInvalid": "\"{{site}}\" não é um site utilizável e foi ignorado.",
|
||||
"unrecognizedFormat": "Isto não é JSON, nem um cookies.txt do Netscape, nem uma lista nome=valor.",
|
||||
"siteRequired": "Uma lista nome=valor não carrega domínio. Informe o site ao qual esses cookies pertencem.",
|
||||
"noCookiesFound": "Nenhum cookie pôde ser lido desta colagem.",
|
||||
"nameEmpty": "O nome do cookie está vazio.",
|
||||
"nameInvalid": "\"{{name}}\" não é um nome de cookie utilizável.",
|
||||
"nameMissing": "Esta entrada não tem nome.",
|
||||
"valueInvalid": "O valor de \"{{name}}\" contém caracteres que um cookie não pode carregar.",
|
||||
"valueCoerced": "O valor de \"{{name}}\" não era texto, por isso foi convertido para texto.",
|
||||
"domainFromSite": "\"{{name}}\" não trazia domínio e foi associado a {{domain}}.",
|
||||
"domainMissing": "\"{{name}}\" não traz domínio e nenhum site foi informado.",
|
||||
"domainInvalid": "\"{{name}}\" indica um domínio que não pode ser usado: {{domain}}.",
|
||||
"domainAttributeIgnored": "O atributo Domain={{domain}} foi ignorado em favor do site que você informou, {{site}}.",
|
||||
"hostOnlyMismatch": "\"{{name}}\" declara hostOnly={{hostOnly}} mas seu domínio era {{domain}}. A marca foi aplicada.",
|
||||
"pathRepaired": "O caminho de \"{{name}}\" foi corrigido a partir de {{path}}.",
|
||||
"expiryMilliseconds": "A expiração de \"{{name}}\" ({{expires}}) estava em milissegundos e foi convertida para segundos.",
|
||||
"expiryClamped": "Uma expiração estava longe demais no futuro para ser real e foi limitada ao máximo.",
|
||||
"expiryInvalid": "{{field}} não é um carimbo de tempo utilizável: {{value}}.",
|
||||
"expiresInvalid": "Expires não é uma data que possa ser lida: {{value}}.",
|
||||
"maxAgeInvalid": "Max-Age não é um número: {{value}}.",
|
||||
"maxAgeDeletion": "O Max-Age de \"{{name}}\" o exclui imediatamente.",
|
||||
"sameSiteNoneInsecure": "\"{{name}}\" em {{domain}} é SameSite=None mas não Secure, então o navegador se recusará a enviá-lo.",
|
||||
"sameSiteUnrecognized": "O SameSite \"{{value}}\" não foi reconhecido e ficou não especificado.",
|
||||
"duplicateCookie": "\"{{name}}\" para {{domain}}{{path}} aparece novamente mais adiante na colagem. A cópia posterior vence.",
|
||||
"boolCoercedFromString": "{{field}} era o texto \"{{value}}\" em vez de true ou false, e foi lido como booleano.",
|
||||
"boolInvalid": "{{field}} não é nem true nem false: {{value}}.",
|
||||
"quotedValue": "As aspas em torno do valor de \"{{name}}\" foram removidas.",
|
||||
"jsonParseFailed": "Não foi possível ler o JSON: {{message}}",
|
||||
"jsonNotCookieList": "O JSON não é nem uma matriz de cookies nem um objeto que contenha uma matriz cookies.",
|
||||
"jsonEntryNotObject": "Esta entrada não é um objeto JSON.",
|
||||
"netscapePathOmitted": "Esta linha não tem coluna de caminho, então / foi usado.",
|
||||
"netscapeFieldCount": "Esta linha tem {{actual}} colunas; uma linha de cookie Netscape tem {{expected}}.",
|
||||
"netscapeIncludeSubdomainsInvalid": "A coluna de incluir subdomínios não é nem TRUE nem FALSE: {{value}}.",
|
||||
"netscapeSecureInvalid": "A coluna secure não é nem TRUE nem FALSE: {{value}}.",
|
||||
"netscapeExpiryInvalid": "A coluna de expiração não é um número: {{value}}. A linha foi descartada em vez de virar um cookie ativo.",
|
||||
"nameValueNoPair": "Esta parte não tem um par nome=valor e foi ignorada.",
|
||||
"pairTreatedAsAttribute": "\"{{name}}\" foi lido como um atributo Set-Cookie em vez de um cookie, e o seu valor foi descartado.",
|
||||
"unknown": "{{code}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
@@ -1086,7 +1220,9 @@
|
||||
"brandVersion": "Versão da Marca",
|
||||
"proFeature": "Este é um recurso Pro",
|
||||
"generateFingerprint": "Gerar Impressão Digital",
|
||||
"refreshFingerprint": "Atualizar Impressão Digital",
|
||||
"regenerateFingerprint": "Regenerar Impressão Digital",
|
||||
"regenerateConfirmTitle": "Regenerar esta impressão digital?",
|
||||
"regenerateConfirmDescription": "O perfil mantém os cookies e as sessões, mas passará a apresentar um dispositivo diferente. Sites que já conhecem este perfil podem pedir que você entre novamente, aplicar uma verificação ou bloquear a conta. Só regenere um perfil que ainda não usou ou que esteja disposto a perder. Esta ação não pode ser desfeita.",
|
||||
"canvasNoiseSeedPlaceholder": "Insira uma string seed para a impressão digital do canvas",
|
||||
"addFontsPlaceholder": "Adicionar fontes...",
|
||||
"enterAsJson": "Insira {{title}} como JSON"
|
||||
@@ -1888,6 +2024,10 @@
|
||||
"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.",
|
||||
"cookieImportBrowserRunning": "Não é possível importar cookies enquanto o navegador está em execução. Feche-o e tente novamente.",
|
||||
"cookieImportProfileProtected": "Não é possível importar cookies para um perfil protegido por senha. Remova a senha primeiro.",
|
||||
"cookieImportRemoteSession": "Não é possível importar cookies enquanto uma sessão remota controla este perfil. Aguarde a sincronização terminar.",
|
||||
"cookieImportNoCookies": "Nenhum cookie foi encontrado no que você colou.",
|
||||
"selfHostedRequiresLogout": "Saia da sua conta Donut antes de configurar um servidor auto-hospedado.",
|
||||
"fingerprintRequiresPro": "Visualizar ou editar a impressão digital requer um plano pago ativo. A proteção está incluída em todos os planos.",
|
||||
"proxyNotWorking": "O proxy selecionado não está funcionando, então o perfil não foi criado.",
|
||||
@@ -1896,6 +2036,8 @@
|
||||
"camoufoxImportDeprecated": "A importação deste tipo de perfil não é mais suportada. Use o Wayfern em vez disso.",
|
||||
"updateChecksumsUnavailable": "Não foi possível verificar a atualização {{version}} porque o arquivo de somas de verificação não pôde ser obtido. A atualização não foi instalada; será tentada novamente mais tarde.",
|
||||
"updateChecksumMismatch": "O arquivo de atualização baixado {{file}} falhou na verificação de soma de verificação e foi descartado. Tente novamente.",
|
||||
"browserChecksumUnavailable": "Não foi possível verificar o {{browser}} {{version}} porque o arquivo de somas de verificação não pôde ser obtido. O download foi interrompido; será tentado novamente mais tarde.",
|
||||
"browserChecksumMismatch": "O arquivo baixado do {{browser}} {{version}} falhou na verificação de soma de verificação e foi descartado. Tente novamente.",
|
||||
"nameCannotBeEmpty": "O nome não pode estar vazio",
|
||||
"wayfernVersionNotAvailable": "A versão {{requested}} do Wayfern não está disponível para download. A versão atual é {{current}}.",
|
||||
"profileNameExists": "Já existe um perfil chamado \"{{name}}\"",
|
||||
|
||||
+156
-14
@@ -134,7 +134,11 @@
|
||||
"title": "Браузер по умолчанию",
|
||||
"setAsDefault": "Установить браузером по умолчанию",
|
||||
"alreadyDefault": "Уже браузер по умолчанию",
|
||||
"description": "При установке по умолчанию Donut Browser будет обрабатывать веб-ссылки и позволит выбрать профиль для использования."
|
||||
"description": "При установке по умолчанию Donut Browser будет обрабатывать веб-ссылки и позволит выбрать профиль для использования.",
|
||||
"setSuccess": "Donut Browser теперь браузер по умолчанию",
|
||||
"setFailed": "Не удалось назначить браузер по умолчанию",
|
||||
"finishInSystemSettings": "Завершите в параметрах Windows",
|
||||
"finishInSystemSettingsDescription": "Donut Browser зарегистрирован. Параметры Windows открыты: выберите Donut Browser в разделе «Веб-браузер», чтобы завершить."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Системные разрешения",
|
||||
@@ -228,6 +232,49 @@
|
||||
"scrollGroupsLeft": "Прокрутить группы влево",
|
||||
"scrollGroupsRight": "Прокрутить группы вправо"
|
||||
},
|
||||
"search": {
|
||||
"helpLabel": "Синтаксис поиска",
|
||||
"helpTitle": "Синтаксис поиска",
|
||||
"helpIntro": "Введите слова, чтобы искать по названиям, заметкам, тегам и идентификаторам. Добавьте поля, чтобы сузить поиск.",
|
||||
"fieldsTitle": "Поля",
|
||||
"operatorsTitle": "Операторы",
|
||||
"examplesTitle": "Примеры",
|
||||
"fields": {
|
||||
"name": "Название профиля",
|
||||
"tag": "Тег",
|
||||
"note": "Заметка",
|
||||
"id": "Идентификатор профиля, совпадение с начала",
|
||||
"group": "Название группы",
|
||||
"proxy": "Название прокси",
|
||||
"vpn": "Название VPN",
|
||||
"ext": "Название группы расширений",
|
||||
"dns": "Список блокировки DNS",
|
||||
"os": "Операционная система",
|
||||
"browser": "Браузер",
|
||||
"status": "Запущен или нет",
|
||||
"sync": "Режим синхронизации",
|
||||
"email": "Эл. почта владельца",
|
||||
"version": "Версия браузера",
|
||||
"locked": "Защищён паролем",
|
||||
"ephemeral": "Временный профиль",
|
||||
"created": "Дата создания",
|
||||
"launched": "Дата последнего запуска"
|
||||
},
|
||||
"operators": {
|
||||
"negate": "Исключает совпадения",
|
||||
"quote": "Удерживает значение с пробелами как одно целое",
|
||||
"or": "Совпадает с любым из двух условий",
|
||||
"comma": "Сокращение для любого из значений",
|
||||
"exact": "Совпадает со всем значением, а не с его частью",
|
||||
"none": "Здесь ничего не задано; для обратного используйте any",
|
||||
"compare": "Сравнивает даты и версии; 7d, 3w и 6m отсчитываются назад от текущего момента"
|
||||
},
|
||||
"examples": {
|
||||
"a": "Запущенные профили в одной группе",
|
||||
"b": "Профили без тегов, у которых есть прокси",
|
||||
"c": "Не запускались более 30 дней, кроме архивных"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"title": "Профили",
|
||||
"empty": "Профилей пока нет",
|
||||
@@ -237,6 +284,7 @@
|
||||
"noResultsDescription": "Нет профилей, соответствующих критериям поиска.",
|
||||
"table": {
|
||||
"name": "Название",
|
||||
"none": "Нет",
|
||||
"browser": "Браузер",
|
||||
"status": "Статус",
|
||||
"actions": "Действия",
|
||||
@@ -245,10 +293,8 @@
|
||||
"proxy": "Прокси / VPN",
|
||||
"lastLaunch": "Последний запуск",
|
||||
"empty": "Профили не найдены.",
|
||||
"notSelected": "Не выбрано",
|
||||
"ext": "РАСШ",
|
||||
"dns": "DNS",
|
||||
"extDefault": "По умолч.",
|
||||
"dnsLevel": "DNS-блок-лист: {{level}}",
|
||||
"extSearch": "Поиск групп…",
|
||||
"extEmpty": "Нет групп расширений",
|
||||
@@ -262,7 +308,8 @@
|
||||
"emptyImport": "Импортировать профили",
|
||||
"emptyFilteredTitle": "Профили не найдены",
|
||||
"emptyFilteredHint": "Нет профилей для этой группы или запроса. Попробуйте другой фильтр или создайте профиль.",
|
||||
"bot": "Бот"
|
||||
"bot": "Бот",
|
||||
"profileId": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Запустить",
|
||||
@@ -638,6 +685,8 @@
|
||||
"serverError": "Сервер вернул ошибку",
|
||||
"connectFailed": "Не удалось подключиться к серверу",
|
||||
"storageEndpoint": "Хранилище: {{endpoint}}",
|
||||
"storageUnreachableStatus": "Хранилище недоступно",
|
||||
"storageUnreachable": "Сервер доступен, но адрес хранилища {{endpoint}} недоступен с этого устройства. Передача файлов работать не будет. Если вы используете собственный сервер, укажите в S3_PUBLIC_ENDPOINT адрес, доступный с этого устройства.",
|
||||
"settingsSaved": "Настройки синхронизации сохранены",
|
||||
"saveFailed": "Не удалось сохранить настройки",
|
||||
"disconnected": "Синхронизация отключена",
|
||||
@@ -856,12 +905,7 @@
|
||||
"menuItem": "Управление Cookies",
|
||||
"tabImport": "Импорт",
|
||||
"tabExport": "Экспорт",
|
||||
"importDescription": "Импортируйте cookies из файла в формате Netscape или JSON.",
|
||||
"dropPrompt": "Нажмите, чтобы выбрать файл cookies",
|
||||
"fileFormats": "(.txt, .cookies или .json)",
|
||||
"cookiesFound": "Найдено cookies: {{count}}",
|
||||
"importedSuccess": "Импортировано {{imported}} cookies ({{replaced}} заменено)",
|
||||
"linesSkipped": "Пропущено строк: {{count}}",
|
||||
"importDescription": "Вставьте cookie, скопированные из другого браузера или инструмента, либо выберите файл.",
|
||||
"fileReadError": "Не удалось прочитать файл",
|
||||
"loadFailed": "Не удалось загрузить cookies: {{error}}",
|
||||
"cookiesLabel": "Cookies",
|
||||
@@ -870,9 +914,7 @@
|
||||
"deselectAll": "Снять выбор",
|
||||
"noCookies": "Cookies в этом профиле не найдены",
|
||||
"doneButton": "Готово",
|
||||
"importButton": "Импорт",
|
||||
"exportButton": "Экспорт",
|
||||
"backButton": "Назад"
|
||||
"exportButton": "Экспорт"
|
||||
},
|
||||
"import": {
|
||||
"title": "Импорт Cookies",
|
||||
@@ -891,6 +933,98 @@
|
||||
"json": "JSON",
|
||||
"success": "Cookies успешно экспортированы",
|
||||
"error": "Ошибка экспорта cookies"
|
||||
},
|
||||
"paste": {
|
||||
"label": "Cookie",
|
||||
"placeholder": "Вставьте cookie сюда. JSON (массив или объект {cookies: [...]}), cookies.txt в формате Netscape или name=value; name2=value2",
|
||||
"chooseFile": "или выберите файл",
|
||||
"analyzing": "Проверка…",
|
||||
"formatJson": "JSON",
|
||||
"formatNetscape": "Netscape",
|
||||
"formatNameValue": "Name=Value",
|
||||
"formatUnknown": "Формат не распознан",
|
||||
"siteLabel": "Сайт",
|
||||
"sitePlaceholder": "example.com или https://example.com",
|
||||
"siteHelp": "Список name=value не содержит домена, поэтому укажите сайт, к которому относятся эти cookie.",
|
||||
"scopeSubdomains": "{{domain}}: этот домен и все его поддомены",
|
||||
"scopeHostOnly": "{{domain}}: только этот хост, без поддоменов",
|
||||
"modeMerge": "Объединить",
|
||||
"modeMergeDesc": "Обновляет сохранённые cookie, совпавшие с вставленными, добавляет остальные и ничего не удаляет.",
|
||||
"modeReplace": "Заменить совпадающие сайты",
|
||||
"modeReplaceDesc": "Удаляет сохранённые cookie этого профиля для сайтов из этой вставки, как с точкой в начале, так и без неё, а затем записывает вставленное. Cookie всех остальных сайтов сохраняются.",
|
||||
"replaceDeleteCount": "Сохранённых cookie будет удалено: {{n}}",
|
||||
"unknownCount": "неизвестно",
|
||||
"includeExpired": "Импортировать также уже истёкшие cookie",
|
||||
"expiredNote": "Уже истекли в этой вставке: {{n}}",
|
||||
"clearsOnCloseWarning": "Этот профиль стирает данные просмотра при закрытии браузера, поэтому эти cookie будут удалены в конце следующего сеанса.",
|
||||
"previewTitle": "Cookie к импорту: {{n}}",
|
||||
"colSite": "Сайт",
|
||||
"colName": "Имя",
|
||||
"colPath": "Путь",
|
||||
"colExpires": "Истекает",
|
||||
"colSecure": "Secure",
|
||||
"colHttpOnly": "HttpOnly",
|
||||
"colSameSite": "SameSite",
|
||||
"session": "Сеанс",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"sameSiteUnspecified": "Не указано",
|
||||
"sameSiteNone": "None",
|
||||
"sameSiteLax": "Lax",
|
||||
"sameSiteStrict": "Strict",
|
||||
"issuesTitle": "Замечания",
|
||||
"showAll": "Показать все: {{n}}",
|
||||
"showFewer": "Свернуть",
|
||||
"sourceLine": "Строка {{n}}",
|
||||
"sourceCookie": "Cookie {{n}}",
|
||||
"disabledEmpty": "Вставьте cookie выше, чтобы их импортировать.",
|
||||
"disabledSite": "Укажите сайт, к которому относятся эти cookie.",
|
||||
"disabledNoCookies": "Из этой вставки не удалось прочитать ни одной cookie.",
|
||||
"resultAdded": "Добавлено",
|
||||
"resultOverwritten": "Перезаписано",
|
||||
"resultDeleted": "Удалено",
|
||||
"resultSkipped": "Пропущено",
|
||||
"issues": {
|
||||
"emptyInput": "Пока ничего не вставлено.",
|
||||
"siteInvalid": "«{{site}}» не является пригодным сайтом и был проигнорирован.",
|
||||
"unrecognizedFormat": "Это не JSON, не cookies.txt в формате Netscape и не список name=value.",
|
||||
"siteRequired": "Список name=value не содержит домена. Укажите сайт, к которому относятся эти cookie.",
|
||||
"noCookiesFound": "Из этой вставки не удалось прочитать ни одной cookie.",
|
||||
"nameEmpty": "Имя cookie пустое.",
|
||||
"nameInvalid": "«{{name}}» не является пригодным именем cookie.",
|
||||
"nameMissing": "У этой записи нет имени.",
|
||||
"valueInvalid": "Значение «{{name}}» содержит символы, недопустимые в cookie.",
|
||||
"valueCoerced": "Значение «{{name}}» не было текстом, поэтому оно преобразовано в текст.",
|
||||
"domainFromSite": "У «{{name}}» не было домена, она привязана к {{domain}}.",
|
||||
"domainMissing": "У «{{name}}» нет домена, и сайт не указан.",
|
||||
"domainInvalid": "«{{name}}» указывает непригодный домен: {{domain}}.",
|
||||
"domainAttributeIgnored": "Атрибут Domain={{domain}} проигнорирован в пользу указанного вами сайта {{site}}.",
|
||||
"hostOnlyMismatch": "У «{{name}}» указано hostOnly={{hostOnly}}, но домен был {{domain}}. Применён флаг.",
|
||||
"pathRepaired": "Путь «{{name}}» исправлен из {{path}}.",
|
||||
"expiryMilliseconds": "Срок действия «{{name}}» ({{expires}}) был в миллисекундах и переведён в секунды.",
|
||||
"expiryClamped": "Срок действия был слишком далёким, чтобы быть настоящим, и ограничен максимумом.",
|
||||
"expiryInvalid": "{{field}} не является пригодной меткой времени: {{value}}.",
|
||||
"expiresInvalid": "Expires не является читаемой датой: {{value}}.",
|
||||
"maxAgeInvalid": "Max-Age не является числом: {{value}}.",
|
||||
"maxAgeDeletion": "Max-Age у «{{name}}» удаляет её немедленно.",
|
||||
"sameSiteNoneInsecure": "«{{name}}» на {{domain}} имеет SameSite=None без Secure, поэтому браузер откажется её отправлять.",
|
||||
"sameSiteUnrecognized": "Значение SameSite «{{value}}» не распознано и оставлено неуказанным.",
|
||||
"duplicateCookie": "«{{name}}» для {{domain}}{{path}} встречается дальше в вставке ещё раз. Побеждает последняя копия.",
|
||||
"boolCoercedFromString": "{{field}} было текстом «{{value}}» вместо true или false и было прочитано как логическое значение.",
|
||||
"boolInvalid": "{{field}} не равно ни true, ни false: {{value}}.",
|
||||
"quotedValue": "Кавычки вокруг значения «{{name}}» удалены.",
|
||||
"jsonParseFailed": "Не удалось прочитать JSON: {{message}}",
|
||||
"jsonNotCookieList": "Этот JSON не является ни массивом cookie, ни объектом с массивом cookies.",
|
||||
"jsonEntryNotObject": "Эта запись не является объектом JSON.",
|
||||
"netscapePathOmitted": "В этой строке нет столбца пути, поэтому использован /.",
|
||||
"netscapeFieldCount": "В этой строке {{actual}} столбцов; в строке cookie Netscape их {{expected}}.",
|
||||
"netscapeIncludeSubdomainsInvalid": "Столбец включения поддоменов не равен ни TRUE, ни FALSE: {{value}}.",
|
||||
"netscapeSecureInvalid": "Столбец secure не равен ни TRUE, ни FALSE: {{value}}.",
|
||||
"netscapeExpiryInvalid": "Столбец срока действия не является числом: {{value}}. Строка отброшена, а не превращена в действующую cookie.",
|
||||
"nameValueNoPair": "В этой части нет пары name=value, она проигнорирована.",
|
||||
"pairTreatedAsAttribute": "«{{name}}» прочитано как атрибут Set-Cookie, а не как куки, и его значение отброшено.",
|
||||
"unknown": "{{code}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
@@ -1089,7 +1223,9 @@
|
||||
"brandVersion": "Версия бренда",
|
||||
"proFeature": "Это функция Pro",
|
||||
"generateFingerprint": "Сгенерировать отпечаток",
|
||||
"refreshFingerprint": "Обновить отпечаток",
|
||||
"regenerateFingerprint": "Пересоздать отпечаток",
|
||||
"regenerateConfirmTitle": "Пересоздать этот отпечаток?",
|
||||
"regenerateConfirmDescription": "Профиль сохранит куки и сессии, но будет выглядеть как другое устройство. Сайты, которые уже знают этот профиль, могут потребовать повторный вход, показать проверку или заблокировать аккаунт. Пересоздавайте только тот профиль, который вы ещё не использовали или готовы потерять. Отменить это действие нельзя.",
|
||||
"canvasNoiseSeedPlaceholder": "Введите строку-семя для отпечатка canvas",
|
||||
"addFontsPlaceholder": "Добавить шрифты...",
|
||||
"enterAsJson": "Введите {{title}} в формате JSON"
|
||||
@@ -1895,6 +2031,10 @@
|
||||
"invalidLaunchHookUrl": "Неверный URL хука запуска. Используйте полный URL http:// или https://.",
|
||||
"cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.",
|
||||
"cookieDbUnavailable": "Не удалось прочитать куки — хранилище куки недоступно.",
|
||||
"cookieImportBrowserRunning": "Нельзя импортировать куки, пока браузер запущен. Закройте его и попробуйте снова.",
|
||||
"cookieImportProfileProtected": "Нельзя импортировать куки в профиль, защищённый паролем. Сначала снимите пароль.",
|
||||
"cookieImportRemoteSession": "Нельзя импортировать куки, пока профиль занят удалённой сессией. Дождитесь окончания синхронизации.",
|
||||
"cookieImportNoCookies": "В том, что вы вставили, куки не найдены.",
|
||||
"selfHostedRequiresLogout": "Выйдите из аккаунта Donut, прежде чем настраивать собственный сервер.",
|
||||
"fingerprintRequiresPro": "Для просмотра или редактирования отпечатка требуется активный платный план. Защита включена во все планы.",
|
||||
"proxyNotWorking": "Выбранный прокси не работает, поэтому профиль не создан.",
|
||||
@@ -1903,6 +2043,8 @@
|
||||
"camoufoxImportDeprecated": "Импорт профилей этого типа больше не поддерживается. Используйте Wayfern.",
|
||||
"updateChecksumsUnavailable": "Не удалось проверить обновление {{version}}: файл контрольных сумм не удалось получить. Обновление не было установлено; попытка будет повторена позже.",
|
||||
"updateChecksumMismatch": "Загруженный файл обновления {{file}} не прошёл проверку контрольной суммы и был удалён. Попробуйте ещё раз.",
|
||||
"browserChecksumUnavailable": "Не удалось проверить {{browser}} {{version}}: файл контрольных сумм не удалось получить. Загрузка остановлена; попытка будет повторена позже.",
|
||||
"browserChecksumMismatch": "Загруженный архив {{browser}} {{version}} не прошёл проверку контрольной суммы и был удалён. Попробуйте ещё раз.",
|
||||
"nameCannotBeEmpty": "Имя не может быть пустым",
|
||||
"wayfernVersionNotAvailable": "Версия Wayfern {{requested}} недоступна для загрузки. Текущая версия — {{current}}.",
|
||||
"profileNameExists": "Профиль с именем «{{name}}» уже существует",
|
||||
|
||||
+156
-14
@@ -134,7 +134,11 @@
|
||||
"title": "Varsayılan Tarayıcı",
|
||||
"setAsDefault": "Varsayılan Tarayıcı Olarak Ayarla",
|
||||
"alreadyDefault": "Zaten Varsayılan Tarayıcı",
|
||||
"description": "Varsayılan olarak ayarlandığında, Donut Browser web bağlantılarını yönetir ve hangi profilin kullanılacağını seçmenize olanak tanır."
|
||||
"description": "Varsayılan olarak ayarlandığında, Donut Browser web bağlantılarını yönetir ve hangi profilin kullanılacağını seçmenize olanak tanır.",
|
||||
"setSuccess": "Donut Browser artık varsayılan tarayıcınız",
|
||||
"setFailed": "Varsayılan tarayıcı ayarlanamadı",
|
||||
"finishInSystemSettings": "Windows Ayarları'nda tamamlayın",
|
||||
"finishInSystemSettingsDescription": "Donut Browser kaydedildi. Windows Ayarları açıldı: tamamlamak için Web tarayıcısı bölümünden Donut Browser'ı seçin."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Sistem İzinleri",
|
||||
@@ -228,6 +232,49 @@
|
||||
"scrollGroupsLeft": "Grupları sola kaydır",
|
||||
"scrollGroupsRight": "Grupları sağa kaydır"
|
||||
},
|
||||
"search": {
|
||||
"helpLabel": "Arama söz dizimi",
|
||||
"helpTitle": "Arama söz dizimi",
|
||||
"helpIntro": "Adlarda, notlarda, etiketlerde ve kimliklerde aramak için kelime yazın. Daraltmak için alan ekleyin.",
|
||||
"fieldsTitle": "Alanlar",
|
||||
"operatorsTitle": "Operatörler",
|
||||
"examplesTitle": "Örnekler",
|
||||
"fields": {
|
||||
"name": "Profil adı",
|
||||
"tag": "Etiket",
|
||||
"note": "Not",
|
||||
"id": "Profil kimliği, baştan eşleşir",
|
||||
"group": "Grup adı",
|
||||
"proxy": "Proxy adı",
|
||||
"vpn": "VPN adı",
|
||||
"ext": "Uzantı grubu adı",
|
||||
"dns": "DNS engelleme listesi",
|
||||
"os": "İşletim sistemi",
|
||||
"browser": "Tarayıcı",
|
||||
"status": "Çalışıyor mu",
|
||||
"sync": "Senkronizasyon modu",
|
||||
"email": "Sahibinin e-postası",
|
||||
"version": "Tarayıcı sürümü",
|
||||
"locked": "Parola korumalı",
|
||||
"ephemeral": "Geçici profil",
|
||||
"created": "Oluşturulma tarihi",
|
||||
"launched": "Son başlatma tarihi"
|
||||
},
|
||||
"operators": {
|
||||
"negate": "Eşleşenleri hariç tutar",
|
||||
"quote": "Boşluk içeren bir değeri bir arada tutar",
|
||||
"or": "İki terimden herhangi biriyle eşleşir",
|
||||
"comma": "Değerlerden herhangi biri için kısayol",
|
||||
"exact": "Bir parçasıyla değil, değerin tamamıyla eşleşir",
|
||||
"none": "Burada bir şey ayarlı değil; tersi için any kullanın",
|
||||
"compare": "Tarihleri ve sürümleri karşılaştırır; 7d, 3w ve 6m şu andan geriye sayar"
|
||||
},
|
||||
"examples": {
|
||||
"a": "Bir gruptaki çalışan profiller",
|
||||
"b": "Etiketi olmayan ama proxy'si olan profiller",
|
||||
"c": "30 günden uzun süredir başlatılmayanlar, arşivlenenler hariç"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"title": "Profiller",
|
||||
"empty": "Henüz profil yok",
|
||||
@@ -237,6 +284,7 @@
|
||||
"noResultsDescription": "Arama kriterlerinizle eşleşen profil yok.",
|
||||
"table": {
|
||||
"name": "Ad",
|
||||
"none": "Yok",
|
||||
"browser": "Tarayıcı",
|
||||
"status": "Durum",
|
||||
"actions": "İşlemler",
|
||||
@@ -245,10 +293,8 @@
|
||||
"proxy": "Proxy / VPN",
|
||||
"lastLaunch": "Son Başlatma",
|
||||
"empty": "Profil bulunamadı.",
|
||||
"notSelected": "Seçilmedi",
|
||||
"ext": "UZN",
|
||||
"dns": "DNS",
|
||||
"extDefault": "Varsayılan",
|
||||
"dnsLevel": "DNS engel listesi: {{level}}",
|
||||
"extSearch": "Gruplarda ara…",
|
||||
"extEmpty": "Uzantı grubu yok",
|
||||
@@ -262,7 +308,8 @@
|
||||
"emptyImport": "Profilleri içe aktar",
|
||||
"emptyFilteredTitle": "Profil bulunamadı",
|
||||
"emptyFilteredHint": "Bu grup veya aramayla eşleşen profil yok. Başka bir filtre deneyin veya yeni bir profil oluşturun.",
|
||||
"bot": "Bot"
|
||||
"bot": "Bot",
|
||||
"profileId": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Başlat",
|
||||
@@ -636,6 +683,8 @@
|
||||
"serverError": "Sunucu bir hatayla yanıt verdi",
|
||||
"connectFailed": "Sunucuya bağlanılamadı",
|
||||
"storageEndpoint": "Depolama: {{endpoint}}",
|
||||
"storageUnreachableStatus": "Depolamaya erişilemiyor",
|
||||
"storageUnreachable": "Sunucuya erişilebiliyor, ancak depolama adresine {{endpoint}} bu cihazdan erişilemiyor. Dosya aktarımları başarısız olacak. Kendi sunucunuzu barındırıyorsanız S3_PUBLIC_ENDPOINT değerini bu cihazdan erişilebilen bir adres olarak ayarlayın.",
|
||||
"settingsSaved": "Eşitleme ayarları kaydedildi",
|
||||
"saveFailed": "Ayarlar kaydedilemedi",
|
||||
"disconnected": "Eşitleme bağlantısı kesildi",
|
||||
@@ -850,12 +899,7 @@
|
||||
"menuItem": "Çerez Yönetimi",
|
||||
"tabImport": "İçe Aktar",
|
||||
"tabExport": "Dışa Aktar",
|
||||
"importDescription": "Netscape veya JSON biçimindeki bir dosyadan çerez içe aktarın.",
|
||||
"dropPrompt": "Bir çerez dosyası seçmek için tıklayın",
|
||||
"fileFormats": "(.txt, .cookies veya .json)",
|
||||
"cookiesFound": "{{count}} çerez bulundu",
|
||||
"importedSuccess": "{{imported}} çerez başarıyla içe aktarıldı ({{replaced}} değiştirildi)",
|
||||
"linesSkipped": "{{count}} satır atlandı",
|
||||
"importDescription": "Başka bir tarayıcıdan veya araçtan kopyalanan çerezleri yapıştırın ya da bir dosya seçin.",
|
||||
"fileReadError": "Dosya okunamadı",
|
||||
"loadFailed": "Çerezler yüklenemedi: {{error}}",
|
||||
"cookiesLabel": "Çerezler",
|
||||
@@ -864,9 +908,7 @@
|
||||
"deselectAll": "Tüm seçimleri kaldır",
|
||||
"noCookies": "Bu profilde çerez bulunamadı",
|
||||
"doneButton": "Bitti",
|
||||
"importButton": "İçe Aktar",
|
||||
"exportButton": "Dışa Aktar",
|
||||
"backButton": "Geri"
|
||||
"exportButton": "Dışa Aktar"
|
||||
},
|
||||
"import": {
|
||||
"title": "Çerezleri İçe Aktar",
|
||||
@@ -885,6 +927,98 @@
|
||||
"json": "JSON",
|
||||
"success": "Çerezler başarıyla dışa aktarıldı",
|
||||
"error": "Çerezler dışa aktarılamadı"
|
||||
},
|
||||
"paste": {
|
||||
"label": "Çerezler",
|
||||
"placeholder": "Çerezleri buraya yapıştırın. JSON (bir dizi veya {cookies: [...]} nesnesi), Netscape cookies.txt ya da ad=değer; ad2=değer2",
|
||||
"chooseFile": "veya bir dosya seçin",
|
||||
"analyzing": "Denetleniyor…",
|
||||
"formatJson": "JSON",
|
||||
"formatNetscape": "Netscape",
|
||||
"formatNameValue": "Ad=Değer",
|
||||
"formatUnknown": "Biçim tanınamadı",
|
||||
"siteLabel": "Site",
|
||||
"sitePlaceholder": "ornek.com veya https://ornek.com",
|
||||
"siteHelp": "ad=değer listesi kendi alan adını taşımaz, bu nedenle bu çerezlerin ait olduğu siteyi belirtin.",
|
||||
"scopeSubdomains": "{{domain}}: bu alan adı ve tüm alt alan adları",
|
||||
"scopeHostOnly": "{{domain}}: yalnızca bu ana bilgisayar, alt alan adları hariç",
|
||||
"modeMerge": "Birleştir",
|
||||
"modeMergeDesc": "Yapıştırılan bir çerezle eşleşen kayıtlı çerezleri günceller, kalanları ekler ve hiçbir şeyi silmez.",
|
||||
"modeReplace": "Eşleşen siteleri değiştir",
|
||||
"modeReplaceDesc": "Bu yapıştırmada geçen siteler için bu profilin kayıtlı çerezlerini hem noktalı hem noktasız biçimiyle siler, ardından yapıştırılanı yazar. Diğer tüm sitelerin çerezleri korunur.",
|
||||
"replaceDeleteCount": "Silinecek kayıtlı çerez sayısı: {{n}}",
|
||||
"unknownCount": "bilinmiyor",
|
||||
"includeExpired": "Süresi dolmuş çerezleri de içe aktar",
|
||||
"expiredNote": "Bu yapıştırmada süresi dolmuş olan: {{n}}",
|
||||
"clearsOnCloseWarning": "Bu profil, tarayıcı kapanınca gezinme verilerini siler; bu nedenle bu çerezler bir sonraki oturumun sonunda silinecek.",
|
||||
"previewTitle": "İçe aktarılacak çerezler: {{n}}",
|
||||
"colSite": "Site",
|
||||
"colName": "Ad",
|
||||
"colPath": "Yol",
|
||||
"colExpires": "Bitiş",
|
||||
"colSecure": "Secure",
|
||||
"colHttpOnly": "HttpOnly",
|
||||
"colSameSite": "SameSite",
|
||||
"session": "Oturum",
|
||||
"yes": "Evet",
|
||||
"no": "Hayır",
|
||||
"sameSiteUnspecified": "Belirtilmemiş",
|
||||
"sameSiteNone": "None",
|
||||
"sameSiteLax": "Lax",
|
||||
"sameSiteStrict": "Strict",
|
||||
"issuesTitle": "Sorunlar",
|
||||
"showAll": "{{n}} tanının tümünü göster",
|
||||
"showFewer": "Daha az göster",
|
||||
"sourceLine": "Satır {{n}}",
|
||||
"sourceCookie": "Çerez {{n}}",
|
||||
"disabledEmpty": "İçe aktarmak için yukarıya çerez yapıştırın.",
|
||||
"disabledSite": "Bu çerezlerin ait olduğu siteyi belirtin.",
|
||||
"disabledNoCookies": "Bu yapıştırmadan hiçbir çerez okunamadı.",
|
||||
"resultAdded": "Eklendi",
|
||||
"resultOverwritten": "Üzerine yazıldı",
|
||||
"resultDeleted": "Silindi",
|
||||
"resultSkipped": "Atlandı",
|
||||
"issues": {
|
||||
"emptyInput": "Henüz hiçbir şey yapıştırılmadı.",
|
||||
"siteInvalid": "\"{{site}}\" kullanılabilir bir site değil ve yok sayıldı.",
|
||||
"unrecognizedFormat": "Bu ne JSON, ne Netscape cookies.txt, ne de ad=değer listesi.",
|
||||
"siteRequired": "ad=değer listesi alan adı taşımaz. Bu çerezlerin ait olduğu siteyi belirtin.",
|
||||
"noCookiesFound": "Bu yapıştırmadan hiçbir çerez okunamadı.",
|
||||
"nameEmpty": "Çerez adı boş.",
|
||||
"nameInvalid": "\"{{name}}\" kullanılabilir bir çerez adı değil.",
|
||||
"nameMissing": "Bu kaydın adı yok.",
|
||||
"valueInvalid": "\"{{name}}\" değeri, bir çerezin taşıyamayacağı karakterler içeriyor.",
|
||||
"valueCoerced": "\"{{name}}\" değeri metin değildi, bu yüzden metne dönüştürüldü.",
|
||||
"domainFromSite": "\"{{name}}\" alan adı taşımıyordu ve {{domain}} ile ilişkilendirildi.",
|
||||
"domainMissing": "\"{{name}}\" alan adı taşımıyor ve site de belirtilmedi.",
|
||||
"domainInvalid": "\"{{name}}\" kullanılamayan bir alan adı belirtiyor: {{domain}}.",
|
||||
"domainAttributeIgnored": "Domain={{domain}} özelliği yok sayıldı; bunun yerine belirttiğiniz site {{site}} kullanıldı.",
|
||||
"hostOnlyMismatch": "\"{{name}}\" hostOnly={{hostOnly}} diyor ancak alan adı {{domain}} idi. Bayrak uygulandı.",
|
||||
"pathRepaired": "\"{{name}}\" yolu {{path}} değerinden düzeltildi.",
|
||||
"expiryMilliseconds": "\"{{name}}\" bitiş zamanı ({{expires}}) milisaniye cinsindendi ve saniyeye çevrildi.",
|
||||
"expiryClamped": "Bir bitiş zamanı gerçek olamayacak kadar uzaktaydı ve azami değere sınırlandı.",
|
||||
"expiryInvalid": "{{field}} kullanılabilir bir zaman damgası değil: {{value}}.",
|
||||
"expiresInvalid": "Expires okunabilir bir tarih değil: {{value}}.",
|
||||
"maxAgeInvalid": "Max-Age bir sayı değil: {{value}}.",
|
||||
"maxAgeDeletion": "\"{{name}}\" üzerindeki Max-Age onu hemen siler.",
|
||||
"sameSiteNoneInsecure": "{{domain}} üzerindeki \"{{name}}\" SameSite=None ancak Secure değil, bu yüzden tarayıcı onu göndermeyi reddedecek.",
|
||||
"sameSiteUnrecognized": "SameSite \"{{value}}\" tanınamadı ve belirtilmemiş bırakıldı.",
|
||||
"duplicateCookie": "{{domain}}{{path}} için \"{{name}}\" yapıştırmanın ilerisinde yeniden geçiyor. Sonraki kopya geçerli olur.",
|
||||
"boolCoercedFromString": "{{field}} true veya false yerine \"{{value}}\" metniydi ve mantıksal değer olarak okundu.",
|
||||
"boolInvalid": "{{field}} ne true ne de false: {{value}}.",
|
||||
"quotedValue": "\"{{name}}\" değerinin çevresindeki tırnaklar kaldırıldı.",
|
||||
"jsonParseFailed": "JSON okunamadı: {{message}}",
|
||||
"jsonNotCookieList": "Bu JSON ne bir çerez dizisi ne de cookies dizisi içeren bir nesne.",
|
||||
"jsonEntryNotObject": "Bu kayıt bir JSON nesnesi değil.",
|
||||
"netscapePathOmitted": "Bu satırda yol sütunu yok, bu yüzden / kullanıldı.",
|
||||
"netscapeFieldCount": "Bu satırda {{actual}} sütun var; bir Netscape çerez satırında {{expected}} sütun bulunur.",
|
||||
"netscapeIncludeSubdomainsInvalid": "Alt alan adlarını dahil etme sütunu ne TRUE ne de FALSE: {{value}}.",
|
||||
"netscapeSecureInvalid": "secure sütunu ne TRUE ne de FALSE: {{value}}.",
|
||||
"netscapeExpiryInvalid": "Bitiş sütunu bir sayı değil: {{value}}. Satır, geçerli bir çereze dönüştürülmek yerine atıldı.",
|
||||
"nameValueNoPair": "Bu parçada ad=değer çifti yok ve yok sayıldı.",
|
||||
"pairTreatedAsAttribute": "\"{{name}}\" bir çerez yerine Set-Cookie özelliği olarak okundu ve değeri atıldı.",
|
||||
"unknown": "{{code}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
@@ -1083,7 +1217,9 @@
|
||||
"brandVersion": "Marka Sürümü",
|
||||
"proFeature": "Bu bir Pro özelliğidir",
|
||||
"generateFingerprint": "Parmak İzi Oluştur",
|
||||
"refreshFingerprint": "Parmak İzini Yenile",
|
||||
"regenerateFingerprint": "Parmak İzini Yeniden Oluştur",
|
||||
"regenerateConfirmTitle": "Bu parmak izi yeniden oluşturulsun mu?",
|
||||
"regenerateConfirmDescription": "Profil çerezlerini ve oturumlarını korur, ancak farklı bir cihaz olarak görünür. Bu profili zaten tanıyan siteler yeniden giriş yapmanızı isteyebilir, doğrulama uygulayabilir veya hesabı engelleyebilir. Yalnızca henüz kullanmadığınız ya da kaybetmeyi göze aldığınız bir profili yeniden oluşturun. Bu işlem geri alınamaz.",
|
||||
"canvasNoiseSeedPlaceholder": "Canvas parmak izi için bir tohum dizesi girin",
|
||||
"addFontsPlaceholder": "Yazı tipi ekleyin...",
|
||||
"enterAsJson": "{{title}} değerini JSON olarak girin"
|
||||
@@ -1881,6 +2017,10 @@
|
||||
"invalidLaunchHookUrl": "Geçersiz başlatma kancası URL'si. Tam bir http:// veya https:// URL'si kullanın.",
|
||||
"cookieDbLocked": "Çerezler okunamadı — veritabanı kilitli. Tarayıcıyı kapatıp yeniden deneyin.",
|
||||
"cookieDbUnavailable": "Çerezler okunamadı — çerez deposu kullanılamıyor.",
|
||||
"cookieImportBrowserRunning": "Tarayıcı çalışırken çerezler içe aktarılamaz. Tarayıcıyı kapatıp yeniden deneyin.",
|
||||
"cookieImportProfileProtected": "Parola korumalı bir profile çerez içe aktarılamaz. Önce parolayı kaldırın.",
|
||||
"cookieImportRemoteSession": "Bu profili bir uzak oturum kullanırken çerezler içe aktarılamaz. Eşitlemenin bitmesini bekleyin.",
|
||||
"cookieImportNoCookies": "Yapıştırdığınız içerikte çerez bulunamadı.",
|
||||
"selfHostedRequiresLogout": "Kendi sunucunuzu yapılandırmadan önce Donut hesabınızdan çıkış yapın.",
|
||||
"fingerprintRequiresPro": "Parmak izini görüntülemek veya düzenlemek etkin bir ücretli plan gerektirir. Koruma tüm planlara dahildir.",
|
||||
"proxyNotWorking": "Seçilen proxy çalışmıyor, bu nedenle profil oluşturulmadı.",
|
||||
@@ -1889,6 +2029,8 @@
|
||||
"camoufoxImportDeprecated": "Bu profil türünün içe aktarılması artık desteklenmiyor. Lütfen bunun yerine Wayfern kullanın.",
|
||||
"updateChecksumsUnavailable": "{{version}} güncellemesi doğrulanamadı çünkü sağlama toplamı dosyası alınamadı. Güncelleme yüklenmedi; daha sonra yeniden denenecek.",
|
||||
"updateChecksumMismatch": "İndirilen güncelleme dosyası {{file}} sağlama toplamı doğrulamasını geçemedi ve silindi. Lütfen yeniden deneyin.",
|
||||
"browserChecksumUnavailable": "{{browser}} {{version}} doğrulanamadı çünkü sağlama toplamı dosyası alınamadı. İndirme durduruldu; daha sonra yeniden denenecek.",
|
||||
"browserChecksumMismatch": "İndirilen {{browser}} {{version}} arşivi sağlama toplamı doğrulamasını geçemedi ve silindi. Lütfen yeniden deneyin.",
|
||||
"nameCannotBeEmpty": "Ad boş olamaz",
|
||||
"wayfernVersionNotAvailable": "Wayfern {{requested}} sürümü indirilemiyor. Güncel sürüm: {{current}}.",
|
||||
"profileNameExists": "\"{{name}}\" adlı bir profil zaten var",
|
||||
|
||||
+156
-14
@@ -134,7 +134,11 @@
|
||||
"title": "Trình duyệt mặc định",
|
||||
"setAsDefault": "Đặt làm trình duyệt mặc định",
|
||||
"alreadyDefault": "Đã là trình duyệt mặc định",
|
||||
"description": "Khi được đặt làm mặc định, Donut Browser sẽ xử lý các liên kết web và cho phép bạn chọn hồ sơ để sử dụng."
|
||||
"description": "Khi được đặt làm mặc định, Donut Browser sẽ xử lý các liên kết web và cho phép bạn chọn hồ sơ để sử dụng.",
|
||||
"setSuccess": "Donut Browser hiện là trình duyệt mặc định của bạn",
|
||||
"setFailed": "Không thể đặt trình duyệt mặc định",
|
||||
"finishInSystemSettings": "Hoàn tất trong Cài đặt Windows",
|
||||
"finishInSystemSettingsDescription": "Donut Browser đã được đăng ký. Cài đặt Windows đang mở: chọn Donut Browser trong mục Trình duyệt web để hoàn tất."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Quyền hệ thống",
|
||||
@@ -228,6 +232,49 @@
|
||||
"scrollGroupsLeft": "Cuộn nhóm sang trái",
|
||||
"scrollGroupsRight": "Cuộn nhóm sang phải"
|
||||
},
|
||||
"search": {
|
||||
"helpLabel": "Cú pháp tìm kiếm",
|
||||
"helpTitle": "Cú pháp tìm kiếm",
|
||||
"helpIntro": "Nhập từ khóa để tìm trong tên, ghi chú, thẻ và id. Thêm trường để thu hẹp kết quả.",
|
||||
"fieldsTitle": "Trường",
|
||||
"operatorsTitle": "Toán tử",
|
||||
"examplesTitle": "Ví dụ",
|
||||
"fields": {
|
||||
"name": "Tên hồ sơ",
|
||||
"tag": "Thẻ",
|
||||
"note": "Ghi chú",
|
||||
"id": "Id hồ sơ, khớp từ đầu",
|
||||
"group": "Tên nhóm",
|
||||
"proxy": "Tên proxy",
|
||||
"vpn": "Tên VPN",
|
||||
"ext": "Tên nhóm tiện ích",
|
||||
"dns": "Danh sách chặn DNS",
|
||||
"os": "Hệ điều hành",
|
||||
"browser": "Trình duyệt",
|
||||
"status": "Đang chạy hay không",
|
||||
"sync": "Chế độ đồng bộ",
|
||||
"email": "Email chủ sở hữu",
|
||||
"version": "Phiên bản trình duyệt",
|
||||
"locked": "Được bảo vệ bằng mật khẩu",
|
||||
"ephemeral": "Hồ sơ tạm thời",
|
||||
"created": "Ngày tạo",
|
||||
"launched": "Ngày khởi chạy gần nhất"
|
||||
},
|
||||
"operators": {
|
||||
"negate": "Loại trừ những gì khớp",
|
||||
"quote": "Giữ nguyên giá trị có dấu cách",
|
||||
"or": "Khớp với một trong hai điều kiện",
|
||||
"comma": "Cách viết tắt cho một trong các giá trị",
|
||||
"exact": "Khớp toàn bộ giá trị, không phải một phần",
|
||||
"none": "Chưa đặt gì ở đây; dùng any cho trường hợp ngược lại",
|
||||
"compare": "So sánh ngày và phiên bản; 7d, 3w và 6m tính lùi từ hiện tại"
|
||||
},
|
||||
"examples": {
|
||||
"a": "Hồ sơ đang chạy trong một nhóm",
|
||||
"b": "Hồ sơ không có thẻ nhưng có proxy",
|
||||
"c": "Không khởi chạy hơn 30 ngày, bỏ qua hồ sơ đã lưu trữ"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"title": "Hồ sơ",
|
||||
"empty": "Chưa có hồ sơ nào",
|
||||
@@ -237,6 +284,7 @@
|
||||
"noResultsDescription": "Không có hồ sơ nào khớp với tiêu chí tìm kiếm.",
|
||||
"table": {
|
||||
"name": "Tên",
|
||||
"none": "Không có",
|
||||
"browser": "Trình duyệt",
|
||||
"status": "Trạng thái",
|
||||
"actions": "Thao tác",
|
||||
@@ -245,10 +293,8 @@
|
||||
"proxy": "Proxy / VPN",
|
||||
"lastLaunch": "Lần chạy cuối",
|
||||
"empty": "Không tìm thấy hồ sơ.",
|
||||
"notSelected": "Chưa chọn",
|
||||
"ext": "TIỆN ÍCH",
|
||||
"dns": "DNS",
|
||||
"extDefault": "Mặc định",
|
||||
"dnsLevel": "Danh sách chặn DNS: {{level}}",
|
||||
"extSearch": "Tìm kiếm nhóm…",
|
||||
"extEmpty": "Không có nhóm tiện ích",
|
||||
@@ -262,7 +308,8 @@
|
||||
"emptyImport": "Nhập hồ sơ",
|
||||
"emptyFilteredTitle": "Không tìm thấy hồ sơ",
|
||||
"emptyFilteredHint": "Không có hồ sơ nào khớp với nhóm hoặc tìm kiếm này. Hãy thử bộ lọc khác hoặc tạo mới.",
|
||||
"bot": "Bot"
|
||||
"bot": "Bot",
|
||||
"profileId": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Khởi chạy",
|
||||
@@ -636,6 +683,8 @@
|
||||
"serverError": "Máy chủ trả về lỗi",
|
||||
"connectFailed": "Kết nối máy chủ thất bại",
|
||||
"storageEndpoint": "Bộ nhớ: {{endpoint}}",
|
||||
"storageUnreachableStatus": "Không thể kết nối tới bộ nhớ",
|
||||
"storageUnreachable": "Máy chủ có thể kết nối được, nhưng thiết bị này không truy cập được địa chỉ bộ nhớ {{endpoint}}. Việc truyền tệp sẽ thất bại. Nếu bạn tự lưu trữ, hãy đặt S3_PUBLIC_ENDPOINT thành địa chỉ mà thiết bị này truy cập được.",
|
||||
"settingsSaved": "Đã lưu cài đặt đồng bộ",
|
||||
"saveFailed": "Lưu cài đặt thất bại",
|
||||
"disconnected": "Đã ngắt kết nối đồng bộ",
|
||||
@@ -850,12 +899,7 @@
|
||||
"menuItem": "Quản lý cookie",
|
||||
"tabImport": "Nhập",
|
||||
"tabExport": "Xuất",
|
||||
"importDescription": "Nhập cookie từ tệp định dạng Netscape hoặc JSON.",
|
||||
"dropPrompt": "Nhấn để chọn tệp cookie",
|
||||
"fileFormats": "(.txt, .cookies, hoặc .json)",
|
||||
"cookiesFound": "Tìm thấy {{count}} cookie",
|
||||
"importedSuccess": "Đã nhập thành công {{imported}} cookie (đã thay thế {{replaced}})",
|
||||
"linesSkipped": "Đã bỏ qua {{count}} dòng",
|
||||
"importDescription": "Dán cookie đã sao chép từ trình duyệt hoặc công cụ khác, hoặc chọn một tệp.",
|
||||
"fileReadError": "Đọc tệp thất bại",
|
||||
"loadFailed": "Tải cookie thất bại: {{error}}",
|
||||
"cookiesLabel": "Cookie",
|
||||
@@ -864,9 +908,7 @@
|
||||
"deselectAll": "Bỏ chọn tất cả",
|
||||
"noCookies": "Không tìm thấy cookie trong profile này",
|
||||
"doneButton": "Xong",
|
||||
"importButton": "Nhập",
|
||||
"exportButton": "Xuất",
|
||||
"backButton": "Quay lại"
|
||||
"exportButton": "Xuất"
|
||||
},
|
||||
"import": {
|
||||
"title": "Nhập cookie",
|
||||
@@ -885,6 +927,98 @@
|
||||
"json": "JSON",
|
||||
"success": "Xuất cookie thành công",
|
||||
"error": "Xuất cookie thất bại"
|
||||
},
|
||||
"paste": {
|
||||
"label": "Cookie",
|
||||
"placeholder": "Dán cookie vào đây. JSON (một mảng hoặc một đối tượng {cookies: [...]}), cookies.txt kiểu Netscape, hoặc name=value; name2=value2",
|
||||
"chooseFile": "hoặc chọn một tệp",
|
||||
"analyzing": "Đang kiểm tra…",
|
||||
"formatJson": "JSON",
|
||||
"formatNetscape": "Netscape",
|
||||
"formatNameValue": "Name=Value",
|
||||
"formatUnknown": "Không nhận ra định dạng",
|
||||
"siteLabel": "Trang",
|
||||
"sitePlaceholder": "example.com hoặc https://example.com",
|
||||
"siteHelp": "Danh sách name=value không mang tên miền riêng, vì vậy hãy chỉ rõ trang mà những cookie này thuộc về.",
|
||||
"scopeSubdomains": "{{domain}}: tên miền này và mọi tên miền phụ",
|
||||
"scopeHostOnly": "{{domain}}: chỉ đúng máy chủ này, không gồm tên miền phụ",
|
||||
"modeMerge": "Hợp nhất",
|
||||
"modeMergeDesc": "Cập nhật các cookie đã lưu trùng với cookie được dán, thêm phần còn lại và không xóa gì.",
|
||||
"modeReplace": "Thay thế các trang trùng",
|
||||
"modeReplaceDesc": "Xóa cookie đã lưu của hồ sơ này cho các trang có trong lần dán này, ở cả dạng có dấu chấm và không dấu chấm, rồi ghi nội dung đã dán. Cookie của mọi trang khác được giữ nguyên.",
|
||||
"replaceDeleteCount": "Số cookie đã lưu sẽ bị xóa: {{n}}",
|
||||
"unknownCount": "không rõ",
|
||||
"includeExpired": "Nhập cả cookie đã hết hạn",
|
||||
"expiredNote": "Đã hết hạn trong lần dán này: {{n}}",
|
||||
"clearsOnCloseWarning": "Hồ sơ này xóa dữ liệu duyệt web khi đóng trình duyệt, nên những cookie này sẽ bị xóa vào cuối phiên kế tiếp.",
|
||||
"previewTitle": "Cookie sẽ nhập: {{n}}",
|
||||
"colSite": "Trang",
|
||||
"colName": "Tên",
|
||||
"colPath": "Đường dẫn",
|
||||
"colExpires": "Hết hạn",
|
||||
"colSecure": "Secure",
|
||||
"colHttpOnly": "HttpOnly",
|
||||
"colSameSite": "SameSite",
|
||||
"session": "Phiên",
|
||||
"yes": "Có",
|
||||
"no": "Không",
|
||||
"sameSiteUnspecified": "Không xác định",
|
||||
"sameSiteNone": "None",
|
||||
"sameSiteLax": "Lax",
|
||||
"sameSiteStrict": "Strict",
|
||||
"issuesTitle": "Vấn đề",
|
||||
"showAll": "Hiển thị tất cả {{n}}",
|
||||
"showFewer": "Thu gọn",
|
||||
"sourceLine": "Dòng {{n}}",
|
||||
"sourceCookie": "Cookie {{n}}",
|
||||
"disabledEmpty": "Dán cookie ở trên để nhập.",
|
||||
"disabledSite": "Hãy chỉ rõ trang mà những cookie này thuộc về.",
|
||||
"disabledNoCookies": "Không đọc được cookie nào từ lần dán này.",
|
||||
"resultAdded": "Đã thêm",
|
||||
"resultOverwritten": "Đã ghi đè",
|
||||
"resultDeleted": "Đã xóa",
|
||||
"resultSkipped": "Đã bỏ qua",
|
||||
"issues": {
|
||||
"emptyInput": "Chưa dán nội dung nào.",
|
||||
"siteInvalid": "\"{{site}}\" không phải là trang dùng được nên đã bị bỏ qua.",
|
||||
"unrecognizedFormat": "Đây không phải JSON, cookies.txt kiểu Netscape hay danh sách name=value.",
|
||||
"siteRequired": "Danh sách name=value không mang tên miền. Hãy chỉ rõ trang mà những cookie này thuộc về.",
|
||||
"noCookiesFound": "Không đọc được cookie nào từ lần dán này.",
|
||||
"nameEmpty": "Tên cookie bỏ trống.",
|
||||
"nameInvalid": "\"{{name}}\" không phải là tên cookie dùng được.",
|
||||
"nameMissing": "Mục này không có tên.",
|
||||
"valueInvalid": "Giá trị của \"{{name}}\" chứa ký tự mà cookie không thể mang.",
|
||||
"valueCoerced": "Giá trị của \"{{name}}\" không phải văn bản nên đã được chuyển thành văn bản.",
|
||||
"domainFromSite": "\"{{name}}\" không có tên miền nên đã được gắn vào {{domain}}.",
|
||||
"domainMissing": "\"{{name}}\" không có tên miền và cũng không có trang nào được chỉ định.",
|
||||
"domainInvalid": "\"{{name}}\" chỉ định một tên miền không dùng được: {{domain}}.",
|
||||
"domainAttributeIgnored": "Thuộc tính Domain={{domain}} đã bị bỏ qua để dùng trang bạn chỉ định, {{site}}.",
|
||||
"hostOnlyMismatch": "\"{{name}}\" ghi hostOnly={{hostOnly}} nhưng tên miền lại là {{domain}}. Cờ đã được áp dụng.",
|
||||
"pathRepaired": "Đường dẫn của \"{{name}}\" đã được sửa từ {{path}}.",
|
||||
"expiryMilliseconds": "Hạn của \"{{name}}\" ({{expires}}) tính bằng mili giây và đã được đổi sang giây.",
|
||||
"expiryClamped": "Một thời hạn xa đến mức không thực tế nên đã bị giới hạn ở mức tối đa.",
|
||||
"expiryInvalid": "{{field}} không phải dấu thời gian dùng được: {{value}}.",
|
||||
"expiresInvalid": "Expires không phải ngày có thể đọc: {{value}}.",
|
||||
"maxAgeInvalid": "Max-Age không phải số: {{value}}.",
|
||||
"maxAgeDeletion": "Max-Age trên \"{{name}}\" xóa nó ngay lập tức.",
|
||||
"sameSiteNoneInsecure": "\"{{name}}\" trên {{domain}} có SameSite=None nhưng không có Secure, nên trình duyệt sẽ từ chối gửi nó.",
|
||||
"sameSiteUnrecognized": "Không nhận ra SameSite \"{{value}}\" nên để là không xác định.",
|
||||
"duplicateCookie": "\"{{name}}\" cho {{domain}}{{path}} xuất hiện lại ở phần sau của nội dung dán. Bản sau được dùng.",
|
||||
"boolCoercedFromString": "{{field}} là văn bản \"{{value}}\" thay vì true hoặc false, và đã được đọc như giá trị luận lý.",
|
||||
"boolInvalid": "{{field}} không phải true cũng không phải false: {{value}}.",
|
||||
"quotedValue": "Đã bỏ dấu nháy quanh giá trị của \"{{name}}\".",
|
||||
"jsonParseFailed": "Không đọc được JSON: {{message}}",
|
||||
"jsonNotCookieList": "JSON này không phải mảng cookie cũng không phải đối tượng chứa mảng cookies.",
|
||||
"jsonEntryNotObject": "Mục này không phải đối tượng JSON.",
|
||||
"netscapePathOmitted": "Dòng này không có cột đường dẫn nên đã dùng /.",
|
||||
"netscapeFieldCount": "Dòng này có {{actual}} cột; một dòng cookie Netscape có {{expected}} cột.",
|
||||
"netscapeIncludeSubdomainsInvalid": "Cột bao gồm tên miền phụ không phải TRUE cũng không phải FALSE: {{value}}.",
|
||||
"netscapeSecureInvalid": "Cột secure không phải TRUE cũng không phải FALSE: {{value}}.",
|
||||
"netscapeExpiryInvalid": "Cột hết hạn không phải số: {{value}}. Dòng này đã bị bỏ thay vì biến thành một cookie còn hiệu lực.",
|
||||
"nameValueNoPair": "Phần này không có cặp name=value nên đã bị bỏ qua.",
|
||||
"pairTreatedAsAttribute": "\"{{name}}\" được đọc là thuộc tính Set-Cookie chứ không phải cookie, và giá trị của nó đã bị bỏ.",
|
||||
"unknown": "{{code}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
@@ -1083,7 +1217,9 @@
|
||||
"brandVersion": "Phiên bản thương hiệu",
|
||||
"proFeature": "Đây là tính năng Pro",
|
||||
"generateFingerprint": "Tạo vân tay",
|
||||
"refreshFingerprint": "Làm mới vân tay",
|
||||
"regenerateFingerprint": "Tạo lại vân tay",
|
||||
"regenerateConfirmTitle": "Tạo lại vân tay này?",
|
||||
"regenerateConfirmDescription": "Hồ sơ vẫn giữ cookie và phiên đăng nhập, nhưng sẽ hiện ra như một thiết bị khác. Các trang đã biết hồ sơ này có thể yêu cầu bạn đăng nhập lại, bắt bạn xác minh, hoặc khóa tài khoản. Chỉ tạo lại hồ sơ mà bạn chưa dùng, hoặc hồ sơ bạn chấp nhận mất. Không thể hoàn tác thao tác này.",
|
||||
"canvasNoiseSeedPlaceholder": "Nhập chuỗi hạt giống cho vân tay canvas",
|
||||
"addFontsPlaceholder": "Thêm phông chữ...",
|
||||
"enterAsJson": "Nhập {{title}} dưới dạng JSON"
|
||||
@@ -1881,6 +2017,10 @@
|
||||
"invalidLaunchHookUrl": "URL hook khởi chạy không hợp lệ. Sử dụng URL http:// hoặc https:// đầy đủ.",
|
||||
"cookieDbLocked": "Không thể đọc cookie — cơ sở dữ liệu bị khóa. Đóng trình duyệt và thử lại.",
|
||||
"cookieDbUnavailable": "Không thể đọc cookie — kho cookie không khả dụng.",
|
||||
"cookieImportBrowserRunning": "Không thể nhập cookie khi trình duyệt đang chạy. Hãy đóng trình duyệt và thử lại.",
|
||||
"cookieImportProfileProtected": "Không thể nhập cookie vào hồ sơ được bảo vệ bằng mật khẩu. Hãy gỡ mật khẩu trước.",
|
||||
"cookieImportRemoteSession": "Không thể nhập cookie khi một phiên từ xa đang giữ hồ sơ này. Hãy đợi quá trình đồng bộ hoàn tất.",
|
||||
"cookieImportNoCookies": "Không tìm thấy cookie nào trong nội dung bạn đã dán.",
|
||||
"selfHostedRequiresLogout": "Đăng xuất khỏi tài khoản Donut trước khi cấu hình máy chủ tự lưu trữ.",
|
||||
"fingerprintRequiresPro": "Xem hoặc chỉnh sửa vân tay yêu cầu gói trả phí đang hoạt động. Tính năng bảo vệ được bao gồm trong mọi gói.",
|
||||
"proxyNotWorking": "Proxy đã chọn không hoạt động, nên profile chưa được tạo.",
|
||||
@@ -1889,6 +2029,8 @@
|
||||
"camoufoxImportDeprecated": "Việc nhập loại hồ sơ này không còn được hỗ trợ. Vui lòng sử dụng Wayfern thay thế.",
|
||||
"updateChecksumsUnavailable": "Không thể xác minh bản cập nhật {{version}} vì không thể tải tệp checksum. Bản cập nhật chưa được cài đặt; sẽ thử lại sau.",
|
||||
"updateChecksumMismatch": "Tệp cập nhật đã tải xuống {{file}} không vượt qua kiểm tra checksum và đã bị loại bỏ. Vui lòng thử lại.",
|
||||
"browserChecksumUnavailable": "Không thể xác minh {{browser}} {{version}} vì không thể tải tệp checksum. Quá trình tải xuống đã dừng; sẽ thử lại sau.",
|
||||
"browserChecksumMismatch": "Kho lưu trữ {{browser}} {{version}} đã tải xuống không vượt qua kiểm tra checksum và đã bị loại bỏ. Vui lòng thử lại.",
|
||||
"nameCannotBeEmpty": "Tên không được để trống",
|
||||
"wayfernVersionNotAvailable": "Phiên bản Wayfern {{requested}} không có sẵn để tải xuống. Phiên bản hiện tại là {{current}}.",
|
||||
"profileNameExists": "Hồ sơ có tên \"{{name}}\" đã tồn tại",
|
||||
|
||||
+156
-14
@@ -134,7 +134,11 @@
|
||||
"title": "默认浏览器",
|
||||
"setAsDefault": "设为默认浏览器",
|
||||
"alreadyDefault": "已是默认浏览器",
|
||||
"description": "设为默认后,Donut Browser 将处理网页链接并允许您选择使用哪个配置文件。"
|
||||
"description": "设为默认后,Donut Browser 将处理网页链接并允许您选择使用哪个配置文件。",
|
||||
"setSuccess": "Donut Browser 现在是您的默认浏览器",
|
||||
"setFailed": "无法设置默认浏览器",
|
||||
"finishInSystemSettings": "请在 Windows 设置中完成",
|
||||
"finishInSystemSettingsDescription": "Donut Browser 已注册。Windows 设置已打开:在“Web 浏览器”中选择 Donut Browser 即可完成。"
|
||||
},
|
||||
"permissions": {
|
||||
"title": "系统权限",
|
||||
@@ -228,6 +232,49 @@
|
||||
"scrollGroupsLeft": "向左滚动分组",
|
||||
"scrollGroupsRight": "向右滚动分组"
|
||||
},
|
||||
"search": {
|
||||
"helpLabel": "搜索语法",
|
||||
"helpTitle": "搜索语法",
|
||||
"helpIntro": "输入文字可搜索名称、备注、标签和 ID。加上字段可进一步筛选。",
|
||||
"fieldsTitle": "字段",
|
||||
"operatorsTitle": "运算符",
|
||||
"examplesTitle": "示例",
|
||||
"fields": {
|
||||
"name": "配置文件名称",
|
||||
"tag": "标签",
|
||||
"note": "备注",
|
||||
"id": "配置文件 ID,从开头匹配",
|
||||
"group": "分组名称",
|
||||
"proxy": "代理名称",
|
||||
"vpn": "VPN 名称",
|
||||
"ext": "扩展分组名称",
|
||||
"dns": "DNS 拦截列表",
|
||||
"os": "操作系统",
|
||||
"browser": "浏览器",
|
||||
"status": "是否正在运行",
|
||||
"sync": "同步模式",
|
||||
"email": "所有者邮箱",
|
||||
"version": "浏览器版本",
|
||||
"locked": "密码保护",
|
||||
"ephemeral": "临时配置文件",
|
||||
"created": "创建日期",
|
||||
"launched": "上次启动日期"
|
||||
},
|
||||
"operators": {
|
||||
"negate": "排除匹配的结果",
|
||||
"quote": "把带空格的值作为整体",
|
||||
"or": "匹配其中任一条件",
|
||||
"comma": "匹配任一值的简写",
|
||||
"exact": "匹配整个值,而不是其中一部分",
|
||||
"none": "此处未设置任何内容;相反的情况用 any",
|
||||
"compare": "比较日期和版本;7d、3w 和 6m 从当前时间往回算"
|
||||
},
|
||||
"examples": {
|
||||
"a": "某个分组中正在运行的配置文件",
|
||||
"b": "没有标签但有代理的配置文件",
|
||||
"c": "超过 30 天未启动,且排除已归档的"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"title": "配置文件",
|
||||
"empty": "暂无配置文件",
|
||||
@@ -237,6 +284,7 @@
|
||||
"noResultsDescription": "没有配置文件匹配您的搜索条件。",
|
||||
"table": {
|
||||
"name": "名称",
|
||||
"none": "无",
|
||||
"browser": "浏览器",
|
||||
"status": "状态",
|
||||
"actions": "操作",
|
||||
@@ -245,10 +293,8 @@
|
||||
"proxy": "代理 / VPN",
|
||||
"lastLaunch": "最后启动",
|
||||
"empty": "未找到配置文件。",
|
||||
"notSelected": "未选择",
|
||||
"ext": "扩展",
|
||||
"dns": "DNS",
|
||||
"extDefault": "默认",
|
||||
"dnsLevel": "DNS 屏蔽列表: {{level}}",
|
||||
"extSearch": "搜索分组…",
|
||||
"extEmpty": "没有扩展组",
|
||||
@@ -262,7 +308,8 @@
|
||||
"emptyImport": "导入配置文件",
|
||||
"emptyFilteredTitle": "未找到配置文件",
|
||||
"emptyFilteredHint": "没有符合此分组或搜索的配置文件。请尝试其他筛选条件或新建一个。",
|
||||
"bot": "机器人"
|
||||
"bot": "机器人",
|
||||
"profileId": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "启动",
|
||||
@@ -636,6 +683,8 @@
|
||||
"serverError": "服务器返回了错误",
|
||||
"connectFailed": "连接服务器失败",
|
||||
"storageEndpoint": "存储: {{endpoint}}",
|
||||
"storageUnreachableStatus": "无法连接存储",
|
||||
"storageUnreachable": "服务器可以连接,但此设备无法访问其存储地址 {{endpoint}}。文件传输将会失败。如果你自建服务器,请将 S3_PUBLIC_ENDPOINT 设置为此设备可以访问的地址。",
|
||||
"settingsSaved": "同步设置已保存",
|
||||
"saveFailed": "保存设置失败",
|
||||
"disconnected": "已断开同步",
|
||||
@@ -850,12 +899,7 @@
|
||||
"menuItem": "Cookie 管理",
|
||||
"tabImport": "导入",
|
||||
"tabExport": "导出",
|
||||
"importDescription": "从 Netscape 或 JSON 格式的文件导入 Cookies。",
|
||||
"dropPrompt": "点击选择 Cookie 文件",
|
||||
"fileFormats": "(.txt、.cookies 或 .json)",
|
||||
"cookiesFound": "找到 {{count}} 个 Cookie",
|
||||
"importedSuccess": "已成功导入 {{imported}} 个 Cookie (替换 {{replaced}} 个)",
|
||||
"linesSkipped": "已跳过 {{count}} 行",
|
||||
"importDescription": "粘贴从其他浏览器或工具复制的 Cookie,或选择一个文件。",
|
||||
"fileReadError": "读取文件失败",
|
||||
"loadFailed": "加载 Cookie 失败: {{error}}",
|
||||
"cookiesLabel": "Cookies",
|
||||
@@ -864,9 +908,7 @@
|
||||
"deselectAll": "取消全选",
|
||||
"noCookies": "此配置文件中未找到 Cookie",
|
||||
"doneButton": "完成",
|
||||
"importButton": "导入",
|
||||
"exportButton": "导出",
|
||||
"backButton": "返回"
|
||||
"exportButton": "导出"
|
||||
},
|
||||
"import": {
|
||||
"title": "导入 Cookies",
|
||||
@@ -885,6 +927,98 @@
|
||||
"json": "JSON",
|
||||
"success": "Cookies 导出成功",
|
||||
"error": "导出 Cookies 失败"
|
||||
},
|
||||
"paste": {
|
||||
"label": "Cookie",
|
||||
"placeholder": "在此粘贴 Cookie。JSON(数组或 {cookies: [...]} 对象)、Netscape cookies.txt,或 name=value; name2=value2",
|
||||
"chooseFile": "或选择文件",
|
||||
"analyzing": "检查中…",
|
||||
"formatJson": "JSON",
|
||||
"formatNetscape": "Netscape",
|
||||
"formatNameValue": "Name=Value",
|
||||
"formatUnknown": "无法识别格式",
|
||||
"siteLabel": "站点",
|
||||
"sitePlaceholder": "example.com 或 https://example.com",
|
||||
"siteHelp": "name=value 列表自身不带域名,请指定这些 Cookie 所属的站点。",
|
||||
"scopeSubdomains": "{{domain}}:该域名及其全部子域名",
|
||||
"scopeHostOnly": "{{domain}}:仅限该主机,不包含子域名",
|
||||
"modeMerge": "合并",
|
||||
"modeMergeDesc": "更新与粘贴内容匹配的已存 Cookie,添加其余的,不删除任何内容。",
|
||||
"modeReplace": "替换匹配的站点",
|
||||
"modeReplaceDesc": "先删除本配置文件中属于本次粘贴所列站点的已存 Cookie(包括带点和不带点两种形式),然后写入粘贴内容。其他站点的 Cookie 均保留。",
|
||||
"replaceDeleteCount": "将被删除的已存 Cookie:{{n}}",
|
||||
"unknownCount": "未知",
|
||||
"includeExpired": "同时导入已过期的 Cookie",
|
||||
"expiredNote": "本次粘贴中已过期:{{n}}",
|
||||
"clearsOnCloseWarning": "本配置文件会在浏览器关闭时清除浏览数据,因此这些 Cookie 将在下一会话结束时被删除。",
|
||||
"previewTitle": "待导入 Cookie:{{n}}",
|
||||
"colSite": "站点",
|
||||
"colName": "名称",
|
||||
"colPath": "路径",
|
||||
"colExpires": "过期时间",
|
||||
"colSecure": "Secure",
|
||||
"colHttpOnly": "HttpOnly",
|
||||
"colSameSite": "SameSite",
|
||||
"session": "会话",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"sameSiteUnspecified": "未指定",
|
||||
"sameSiteNone": "None",
|
||||
"sameSiteLax": "Lax",
|
||||
"sameSiteStrict": "Strict",
|
||||
"issuesTitle": "问题",
|
||||
"showAll": "显示全部 {{n}} 条",
|
||||
"showFewer": "收起",
|
||||
"sourceLine": "第 {{n}} 行",
|
||||
"sourceCookie": "第 {{n}} 个 Cookie",
|
||||
"disabledEmpty": "请在上方粘贴 Cookie 后导入。",
|
||||
"disabledSite": "请指定这些 Cookie 所属的站点。",
|
||||
"disabledNoCookies": "无法从本次粘贴中读取任何 Cookie。",
|
||||
"resultAdded": "已添加",
|
||||
"resultOverwritten": "已覆盖",
|
||||
"resultDeleted": "已删除",
|
||||
"resultSkipped": "已跳过",
|
||||
"issues": {
|
||||
"emptyInput": "尚未粘贴任何内容。",
|
||||
"siteInvalid": "“{{site}}” 不是可用的站点,已忽略。",
|
||||
"unrecognizedFormat": "这既不是 JSON,也不是 Netscape cookies.txt 或 name=value 列表。",
|
||||
"siteRequired": "name=value 列表不带域名。请指定这些 Cookie 所属的站点。",
|
||||
"noCookiesFound": "无法从本次粘贴中读取任何 Cookie。",
|
||||
"nameEmpty": "Cookie 名称为空。",
|
||||
"nameInvalid": "“{{name}}” 不是可用的 Cookie 名称。",
|
||||
"nameMissing": "此条目没有名称。",
|
||||
"valueInvalid": "“{{name}}” 的值包含 Cookie 无法承载的字符。",
|
||||
"valueCoerced": "“{{name}}” 的值不是文本,已转换为文本。",
|
||||
"domainFromSite": "“{{name}}” 未带域名,已关联到 {{domain}}。",
|
||||
"domainMissing": "“{{name}}” 未带域名,也没有指定站点。",
|
||||
"domainInvalid": "“{{name}}” 指定了无法使用的域名:{{domain}}。",
|
||||
"domainAttributeIgnored": "Domain={{domain}} 属性已被忽略,改用你指定的站点 {{site}}。",
|
||||
"hostOnlyMismatch": "“{{name}}” 声明 hostOnly={{hostOnly}},但其域名为 {{domain}}。已按该标志处理。",
|
||||
"pathRepaired": "“{{name}}” 的路径已从 {{path}} 修正。",
|
||||
"expiryMilliseconds": "“{{name}}” 的过期时间({{expires}})以毫秒计,已转换为秒。",
|
||||
"expiryClamped": "某个过期时间远在未来,不可能真实,已限制为最大值。",
|
||||
"expiryInvalid": "{{field}} 不是可用的时间戳:{{value}}。",
|
||||
"expiresInvalid": "Expires 不是可读取的日期:{{value}}。",
|
||||
"maxAgeInvalid": "Max-Age 不是数字:{{value}}。",
|
||||
"maxAgeDeletion": "“{{name}}” 的 Max-Age 会立即删除它。",
|
||||
"sameSiteNoneInsecure": "{{domain}} 上的 “{{name}}” 为 SameSite=None 但未设 Secure,浏览器将拒绝发送它。",
|
||||
"sameSiteUnrecognized": "无法识别 SameSite “{{value}}”,已保留为未指定。",
|
||||
"duplicateCookie": "针对 {{domain}}{{path}} 的 “{{name}}” 在粘贴后面再次出现,以后一份为准。",
|
||||
"boolCoercedFromString": "{{field}} 是文本 “{{value}}” 而非 true 或 false,已按布尔值读取。",
|
||||
"boolInvalid": "{{field}} 既不是 true 也不是 false:{{value}}。",
|
||||
"quotedValue": "已去除 “{{name}}” 值两端的引号。",
|
||||
"jsonParseFailed": "无法读取 JSON:{{message}}",
|
||||
"jsonNotCookieList": "该 JSON 既不是 Cookie 数组,也不是包含 cookies 数组的对象。",
|
||||
"jsonEntryNotObject": "此条目不是 JSON 对象。",
|
||||
"netscapePathOmitted": "此行没有路径列,已使用 /。",
|
||||
"netscapeFieldCount": "此行有 {{actual}} 列;Netscape Cookie 行应为 {{expected}} 列。",
|
||||
"netscapeIncludeSubdomainsInvalid": "包含子域名列既不是 TRUE 也不是 FALSE:{{value}}。",
|
||||
"netscapeSecureInvalid": "secure 列既不是 TRUE 也不是 FALSE:{{value}}。",
|
||||
"netscapeExpiryInvalid": "过期列不是数字:{{value}}。该行已被丢弃,而不是转为有效 Cookie。",
|
||||
"nameValueNoPair": "此部分没有 name=value 对,已忽略。",
|
||||
"pairTreatedAsAttribute": "“{{name}}” 被读作 Set-Cookie 属性而非 Cookie,其值已被丢弃。",
|
||||
"unknown": "{{code}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
@@ -1083,7 +1217,9 @@
|
||||
"brandVersion": "品牌版本",
|
||||
"proFeature": "这是 Pro 功能",
|
||||
"generateFingerprint": "生成指纹",
|
||||
"refreshFingerprint": "刷新指纹",
|
||||
"regenerateFingerprint": "重新生成指纹",
|
||||
"regenerateConfirmTitle": "要重新生成此指纹吗?",
|
||||
"regenerateConfirmDescription": "配置文件会保留 Cookie 和登录状态,但会呈现为另一台设备。已经认识此配置文件的网站可能要求你重新登录、进行验证,或封禁账号。请只对尚未使用过的配置文件,或你愿意舍弃的配置文件执行重新生成。此操作无法撤销。",
|
||||
"canvasNoiseSeedPlaceholder": "输入用于 canvas 指纹的种子字符串",
|
||||
"addFontsPlaceholder": "添加字体...",
|
||||
"enterAsJson": "以 JSON 格式输入 {{title}}"
|
||||
@@ -1881,6 +2017,10 @@
|
||||
"invalidLaunchHookUrl": "启动钩子 URL 无效。请使用完整的 http:// 或 https:// URL。",
|
||||
"cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。",
|
||||
"cookieDbUnavailable": "无法读取 Cookie — Cookie 存储不可用。",
|
||||
"cookieImportBrowserRunning": "浏览器运行时无法导入 Cookie。请关闭浏览器后重试。",
|
||||
"cookieImportProfileProtected": "无法将 Cookie 导入密码保护的配置文件。请先移除密码。",
|
||||
"cookieImportRemoteSession": "远程会话正在占用此配置文件,无法导入 Cookie。请等待同步完成。",
|
||||
"cookieImportNoCookies": "在你粘贴的内容中没有找到 Cookie。",
|
||||
"selfHostedRequiresLogout": "在配置自托管服务器之前请先退出 Donut 账户。",
|
||||
"fingerprintRequiresPro": "查看或编辑指纹需要有效的付费方案。所有方案均包含指纹保护。",
|
||||
"proxyNotWorking": "所选代理无法使用,因此未创建配置文件。",
|
||||
@@ -1889,6 +2029,8 @@
|
||||
"camoufoxImportDeprecated": "不再支持导入此类型的配置文件。请改用 Wayfern。",
|
||||
"updateChecksumsUnavailable": "无法验证更新 {{version}}:无法获取其校验和文件。更新未安装,稍后将重试。",
|
||||
"updateChecksumMismatch": "下载的更新文件 {{file}} 未通过校验和验证,已被丢弃。请重试。",
|
||||
"browserChecksumUnavailable": "无法验证 {{browser}} {{version}}:无法获取其校验和文件。下载已停止,稍后将重试。",
|
||||
"browserChecksumMismatch": "下载的 {{browser}} {{version}} 压缩包未通过校验和验证,已被丢弃。请重试。",
|
||||
"nameCannotBeEmpty": "名称不能为空",
|
||||
"wayfernVersionNotAvailable": "Wayfern 版本 {{requested}} 无法下载。当前版本为 {{current}}。",
|
||||
"profileNameExists": "名为“{{name}}”的配置文件已存在",
|
||||
|
||||
@@ -19,6 +19,10 @@ export type BackendErrorCode =
|
||||
| "INVALID_LAUNCH_HOOK_URL"
|
||||
| "COOKIE_DB_LOCKED"
|
||||
| "COOKIE_DB_UNAVAILABLE"
|
||||
| "COOKIE_IMPORT_BROWSER_RUNNING"
|
||||
| "COOKIE_IMPORT_PROFILE_PROTECTED"
|
||||
| "COOKIE_IMPORT_REMOTE_SESSION"
|
||||
| "COOKIE_IMPORT_NO_COOKIES"
|
||||
| "SELF_HOSTED_REQUIRES_LOGOUT"
|
||||
| "PROXY_NOT_FOUND"
|
||||
| "GROUP_NOT_FOUND"
|
||||
@@ -48,6 +52,8 @@ export type BackendErrorCode =
|
||||
| "PROXY_SIDECAR_VERSION_MISMATCH"
|
||||
| "UPDATE_CHECKSUMS_UNAVAILABLE"
|
||||
| "UPDATE_CHECKSUM_MISMATCH"
|
||||
| "BROWSER_CHECKSUM_UNAVAILABLE"
|
||||
| "BROWSER_CHECKSUM_MISMATCH"
|
||||
| "UPDATE_PROFILES_RUNNING"
|
||||
| "UPDATE_PREPARATION_FAILED"
|
||||
| "PROFILE_NAME_EXISTS"
|
||||
@@ -128,6 +134,21 @@ export type BackendErrorCode =
|
||||
| "COOKIE_BOT_REQUIRES_PROXY"
|
||||
| "COOKIE_BOT_TOUCH_FINGERPRINT_UNSUPPORTED"
|
||||
| "FINGERPRINT_EXIT_MISMATCH"
|
||||
// The launch refuses instead of opening a window on an unmanaged device: a
|
||||
// silent fallback would leave the user browsing a random fingerprint while
|
||||
// the UI reported success, which is the worst failure an anti-detect product
|
||||
// can have. `detail` carries the underlying browser error for support.
|
||||
| "WAYFERN_FINGERPRINT_APPLY_FAILED"
|
||||
| "WAYFERN_FINGERPRINT_GENERATION_FAILED"
|
||||
// Its own code rather than a generation failure: the browser's quota block is
|
||||
// account-wide and lasts 24 hours, so "try again" is wrong advice, and a user
|
||||
// whose every profile refuses at once has to be told this is one limit and
|
||||
// not a broken install.
|
||||
| "WAYFERN_GENERATION_LIMIT_REACHED"
|
||||
// A cross-OS claim needs a signed plan token, so an expired or offline
|
||||
// session cannot apply one. Distinct from the generic apply failure because
|
||||
// signing in again is the fix; `detail` names the claimed OS.
|
||||
| "WAYFERN_CROSS_OS_REQUIRES_PLAN"
|
||||
| "LAUNCH_CONSENT_EXPIRED"
|
||||
| "VPN_WORKER_START_FAILED"
|
||||
| "EXIT_PROBE_FAILED"
|
||||
@@ -207,6 +228,14 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.cookieDbLocked");
|
||||
case "COOKIE_DB_UNAVAILABLE":
|
||||
return t("backendErrors.cookieDbUnavailable");
|
||||
case "COOKIE_IMPORT_BROWSER_RUNNING":
|
||||
return t("backendErrors.cookieImportBrowserRunning");
|
||||
case "COOKIE_IMPORT_PROFILE_PROTECTED":
|
||||
return t("backendErrors.cookieImportProfileProtected");
|
||||
case "COOKIE_IMPORT_REMOTE_SESSION":
|
||||
return t("backendErrors.cookieImportRemoteSession");
|
||||
case "COOKIE_IMPORT_NO_COOKIES":
|
||||
return t("backendErrors.cookieImportNoCookies");
|
||||
case "SELF_HOSTED_REQUIRES_LOGOUT":
|
||||
return t("backendErrors.selfHostedRequiresLogout");
|
||||
case "PROXY_NOT_FOUND":
|
||||
@@ -272,6 +301,16 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.updateChecksumMismatch", {
|
||||
file: parsed.params?.file ?? "",
|
||||
});
|
||||
case "BROWSER_CHECKSUM_UNAVAILABLE":
|
||||
return t("backendErrors.browserChecksumUnavailable", {
|
||||
browser: parsed.params?.browser ?? "",
|
||||
version: parsed.params?.version ?? "",
|
||||
});
|
||||
case "BROWSER_CHECKSUM_MISMATCH":
|
||||
return t("backendErrors.browserChecksumMismatch", {
|
||||
browser: parsed.params?.browser ?? "",
|
||||
version: parsed.params?.version ?? "",
|
||||
});
|
||||
case "UPDATE_PROFILES_RUNNING":
|
||||
return t("backendErrors.updateProfilesRunning");
|
||||
case "UPDATE_PREPARATION_FAILED":
|
||||
@@ -472,6 +511,20 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
// room for one sentence.
|
||||
case "FINGERPRINT_EXIT_MISMATCH":
|
||||
return t("backendErrors.fingerprintExitMismatch");
|
||||
case "WAYFERN_FINGERPRINT_APPLY_FAILED":
|
||||
return t("backendErrors.wayfernFingerprintApplyFailed", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
});
|
||||
case "WAYFERN_FINGERPRINT_GENERATION_FAILED":
|
||||
return t("backendErrors.wayfernFingerprintGenerationFailed", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
});
|
||||
case "WAYFERN_GENERATION_LIMIT_REACHED":
|
||||
return t("backendErrors.wayfernGenerationLimitReached");
|
||||
case "WAYFERN_CROSS_OS_REQUIRES_PLAN":
|
||||
return t("backendErrors.wayfernCrossOsRequiresPlan", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
});
|
||||
case "LAUNCH_CONSENT_EXPIRED":
|
||||
return t("backendErrors.launchConsentExpired");
|
||||
case "VPN_WORKER_START_FAILED":
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
matchesProfile,
|
||||
PROFILE_SEARCH_FIELDS,
|
||||
parseProfileSearch,
|
||||
} from "./profile-search.ts";
|
||||
|
||||
/**
|
||||
* What is pinned here is the promise the table depends on: the box behaves
|
||||
* exactly as it did before for a bare word, a query being typed never blanks
|
||||
* the list, and every field resolves the name a user can see rather than the id
|
||||
* the profile stores.
|
||||
*/
|
||||
|
||||
const NOW = Date.parse("2026-06-15T12:00:00Z");
|
||||
const HOUR = 3600;
|
||||
const DAY = 86400;
|
||||
|
||||
const ctx = {
|
||||
groupNames: new Map([["g1", "Client A"]]),
|
||||
proxyNames: new Map([["p1", "Frankfurt residential"]]),
|
||||
vpnNames: new Map([["v1", "Office WireGuard"]]),
|
||||
extensionGroupNames: new Map([["e1", "Ad blockers"]]),
|
||||
runningProfiles: new Set(["shop"]),
|
||||
now: NOW,
|
||||
};
|
||||
|
||||
function profile(overrides = {}) {
|
||||
return {
|
||||
id: "a1b2c3d4-1111-2222-3333-444455556666",
|
||||
name: "Shopify EU",
|
||||
browser: "wayfern",
|
||||
version: "140.0.3",
|
||||
release_type: "stable",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convenience: does this raw query match this profile? */
|
||||
function hit(query, target, context = ctx) {
|
||||
return matchesProfile(target, parseProfileSearch(query), context);
|
||||
}
|
||||
|
||||
function names(query, targets) {
|
||||
const parsed = parseProfileSearch(query);
|
||||
return targets
|
||||
.filter((p) => matchesProfile(p, parsed, ctx))
|
||||
.map((p) => p.name);
|
||||
}
|
||||
|
||||
test("an empty query matches everything", () => {
|
||||
for (const raw of ["", " ", '""', "\t\n"]) {
|
||||
const parsed = parseProfileSearch(raw);
|
||||
assert.equal(parsed.isEmpty, true, `expected ${JSON.stringify(raw)} empty`);
|
||||
assert.equal(hit(raw, profile()), true);
|
||||
}
|
||||
});
|
||||
|
||||
test("plain text still searches name, note and tags", () => {
|
||||
assert.equal(hit("shopify", profile()), true);
|
||||
assert.equal(hit("SHOPIFY", profile()), true);
|
||||
assert.equal(hit("amazon", profile()), false);
|
||||
assert.equal(hit("renew", profile({ note: "Renew the card" })), true);
|
||||
assert.equal(hit("ads", profile({ tags: ["paid-ads", "eu"] })), true);
|
||||
});
|
||||
|
||||
test("plain text also matches the id by prefix, so the table's short id works", () => {
|
||||
assert.equal(hit("a1b2c3d4", profile()), true);
|
||||
assert.equal(hit("A1B2C3D4", profile()), true);
|
||||
assert.equal(hit("a1b2c3d4-1111-2222-3333-444455556666", profile()), true);
|
||||
// A slice from the middle is not a prefix and must not match.
|
||||
assert.equal(hit("2222", profile()), false);
|
||||
});
|
||||
|
||||
test("a colon inside an ordinary word stays free text", () => {
|
||||
const noted = profile({
|
||||
note: "check https://shop.example.com:8080 at 12:30",
|
||||
});
|
||||
assert.equal(hit("https://shop.example.com:8080", noted), true);
|
||||
assert.equal(hit("12:30", noted), true);
|
||||
// An unknown field name is free text too, never a filter that matches nothing.
|
||||
assert.equal(hit("warmup:done", profile({ note: "warmup:done" })), true);
|
||||
assert.equal(hit("warmup:done", profile()), false);
|
||||
});
|
||||
|
||||
test("field terms match on the resolved name, not the stored id", () => {
|
||||
const target = profile({
|
||||
group_id: "g1",
|
||||
proxy_id: "p1",
|
||||
vpn_id: "v1",
|
||||
extension_group_id: "e1",
|
||||
});
|
||||
assert.equal(hit("group:client", target), true);
|
||||
assert.equal(hit('group:"Client A"', target), true);
|
||||
assert.equal(hit("group:g1", target), false);
|
||||
assert.equal(hit("proxy:frankfurt", target), true);
|
||||
assert.equal(hit("vpn:office", target), true);
|
||||
assert.equal(hit("ext:blockers", target), true);
|
||||
assert.equal(hit("folder:client", target), true, "alias");
|
||||
assert.equal(hit("extension:blockers", target), true, "alias");
|
||||
});
|
||||
|
||||
test("name, note, tag, id, browser, version and email fields", () => {
|
||||
const target = profile({
|
||||
note: "Renew the card",
|
||||
tags: ["prod", "eu"],
|
||||
created_by_email: "ops@example.com",
|
||||
});
|
||||
assert.equal(hit("name:shop", target), true);
|
||||
assert.equal(hit("name:renew", target), false, "name must not read the note");
|
||||
assert.equal(hit("note:card", target), true);
|
||||
assert.equal(hit("notes:card", target), true, "alias");
|
||||
assert.equal(hit("tag:prod", target), true);
|
||||
assert.equal(hit("tags:eu", target), true, "alias");
|
||||
assert.equal(hit("id:a1b2c3d4", target), true);
|
||||
assert.equal(hit("id:b2c3", target), false, "id matches by prefix only");
|
||||
assert.equal(hit("browser:wayfern", target), true);
|
||||
assert.equal(hit("version:140", target), true);
|
||||
assert.equal(hit("email:ops@example.com", target), true);
|
||||
assert.equal(hit("owner:ops", target), true, "alias");
|
||||
});
|
||||
|
||||
test("enum fields match a slug by prefix, so a half-typed value narrows", () => {
|
||||
const running = profile({ id: "shop", name: "Live" });
|
||||
assert.equal(hit("status:running", running), true);
|
||||
assert.equal(hit("status:run", running), true);
|
||||
assert.equal(hit("status:stopped", running), false);
|
||||
assert.equal(hit("status:stopped", profile()), true);
|
||||
assert.equal(hit("os:macos", profile({ host_os: "macos" })), true);
|
||||
assert.equal(
|
||||
hit("os:windows", profile({ wayfern_config: { os: "windows" } })),
|
||||
true,
|
||||
"falls back to the fingerprint OS",
|
||||
);
|
||||
assert.equal(
|
||||
hit("dns:pro_plus", profile({ dns_blocklist: "pro_plus" })),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hit("sync:encrypted", profile({ sync_mode: "Encrypted" })),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hit("sync:disabled", profile()),
|
||||
true,
|
||||
"unset reads as disabled",
|
||||
);
|
||||
});
|
||||
|
||||
test("boolean fields take yes and no", () => {
|
||||
assert.equal(hit("locked:yes", profile({ password_protected: true })), true);
|
||||
assert.equal(hit("locked:no", profile({ password_protected: true })), false);
|
||||
assert.equal(hit("password:no", profile()), true, "alias, unset is false");
|
||||
assert.equal(hit("ephemeral:yes", profile({ ephemeral: true })), true);
|
||||
assert.equal(
|
||||
hit("locked:maybe", profile({ password_protected: true })),
|
||||
false,
|
||||
"an unusable value matches nothing rather than everything",
|
||||
);
|
||||
});
|
||||
|
||||
test("none and any answer the empty question on every relation", () => {
|
||||
const bare = profile();
|
||||
const wired = profile({ proxy_id: "p1", group_id: "g1", tags: ["eu"] });
|
||||
assert.equal(hit("proxy:none", bare), true);
|
||||
assert.equal(hit("proxy:none", wired), false);
|
||||
assert.equal(hit("proxy:any", wired), true);
|
||||
assert.equal(hit("group:none", bare), true);
|
||||
assert.equal(hit("tag:none", bare), true);
|
||||
assert.equal(hit("tag:any", wired), true);
|
||||
assert.equal(hit("note:any", profile({ note: "x" })), true);
|
||||
// A quoted value is the literal word, so a tag really called "none" is findable.
|
||||
assert.equal(hit('tag:"none"', profile({ tags: ["none"] })), true);
|
||||
assert.equal(hit('tag:"none"', bare), false);
|
||||
// A proxy whose stored name no longer resolves still counts as having one.
|
||||
assert.equal(hit("proxy:none", profile({ proxy_id: "gone" })), false);
|
||||
assert.equal(hit("proxy:any", profile({ proxy_id: "gone" })), true);
|
||||
});
|
||||
|
||||
test("negation inverts a term, on both kinds", () => {
|
||||
const tagged = profile({ tags: ["banned"] });
|
||||
assert.equal(hit("-tag:banned", tagged), false);
|
||||
assert.equal(hit("-tag:banned", profile()), true);
|
||||
assert.equal(hit("!tag:banned", tagged), false, "! is an alias for -");
|
||||
assert.equal(hit("tag!=banned", tagged), false);
|
||||
assert.equal(hit("tag!=banned", profile()), true);
|
||||
assert.equal(hit("-shopify", profile()), false);
|
||||
assert.equal(hit("-amazon", profile()), true);
|
||||
});
|
||||
|
||||
test("quotes hold a value together and keep separators literal", () => {
|
||||
const spaced = profile({ tags: ["black friday"], note: "a, b" });
|
||||
assert.equal(hit('tag:"black friday"', spaced), true);
|
||||
assert.equal(
|
||||
hit("tag:black friday", profile({ tags: ["black"] })),
|
||||
false,
|
||||
"unquoted is two terms, and nothing here matches the second",
|
||||
);
|
||||
assert.equal(hit('note:"a, b"', spaced), true, "a quoted comma is literal");
|
||||
assert.equal(hit('"-lead"', profile({ name: "-lead" })), true);
|
||||
});
|
||||
|
||||
test("several terms combine with AND", () => {
|
||||
const target = profile({ tags: ["prod"], group_id: "g1", note: "vat" });
|
||||
assert.equal(hit("tag:prod group:client", target), true);
|
||||
assert.equal(hit("tag:prod group:other", target), false);
|
||||
assert.equal(hit("shopify tag:prod note:vat status:stopped", target), true);
|
||||
assert.equal(hit("shopify tag:prod -note:vat", target), false);
|
||||
});
|
||||
|
||||
test("or joins two terms, and binds tighter than the implicit and", () => {
|
||||
const rows = [
|
||||
profile({ name: "A", tags: ["ads"], id: "shop" }),
|
||||
profile({ name: "B", tags: ["seo"] }),
|
||||
profile({ name: "C", tags: ["other"] }),
|
||||
];
|
||||
assert.deepEqual(names("tag:ads or tag:seo", rows), ["A", "B"]);
|
||||
assert.deepEqual(names("tag:ads or tag:seo status:running", rows), ["A"]);
|
||||
assert.deepEqual(names("tag:ads,seo", rows), ["A", "B"], "comma is or");
|
||||
assert.deepEqual(
|
||||
names("OR tag:ads or", rows),
|
||||
["A"],
|
||||
"a dangling or is ignored",
|
||||
);
|
||||
});
|
||||
|
||||
test("an = prefix forces a whole-value match", () => {
|
||||
const long = profile({ tags: ["production"] });
|
||||
assert.equal(hit("tag:prod", long), true);
|
||||
assert.equal(hit("tag:=prod", long), false);
|
||||
assert.equal(hit("tag:=production", long), true);
|
||||
assert.equal(hit('group:="Client A"', profile({ group_id: "g1" })), true);
|
||||
assert.equal(
|
||||
hit("name:=shopify", profile()),
|
||||
false,
|
||||
"the real name is longer",
|
||||
);
|
||||
});
|
||||
|
||||
test("dates take relative durations, read the way the question is asked", () => {
|
||||
const fresh = profile({ last_launch: NOW / 1000 - 2 * DAY });
|
||||
const cold = profile({ last_launch: NOW / 1000 - 90 * DAY });
|
||||
assert.equal(hit("launched:<7d", fresh), true);
|
||||
assert.equal(hit("launched:<7d", cold), false);
|
||||
assert.equal(
|
||||
hit("launched:>30d", cold),
|
||||
true,
|
||||
"not launched for over 30 days",
|
||||
);
|
||||
assert.equal(hit("launched:>30d", fresh), false);
|
||||
assert.equal(hit("launched:7d", fresh), true, "bare means within");
|
||||
assert.equal(
|
||||
hit("launched:<12h", profile({ last_launch: NOW / 1000 - HOUR })),
|
||||
true,
|
||||
);
|
||||
assert.equal(hit("launched:never", profile()), true);
|
||||
assert.equal(hit("launched:never", fresh), false);
|
||||
assert.equal(hit("launched:any", fresh), true);
|
||||
assert.equal(hit("lastlaunch:<7d", fresh), true, "alias");
|
||||
});
|
||||
|
||||
test("dates take absolute days, months and years", () => {
|
||||
const made = profile({
|
||||
created_at: Date.parse("2026-03-04T10:00:00") / 1000,
|
||||
});
|
||||
assert.equal(hit("created:2026-03-04", made), true);
|
||||
assert.equal(hit("created:2026-03-05", made), false);
|
||||
assert.equal(hit("created:2026-03", made), true);
|
||||
assert.equal(hit("created:2026", made), true);
|
||||
assert.equal(hit("created:>=2026-01-01", made), true);
|
||||
assert.equal(hit("created:<2026-01-01", made), false);
|
||||
assert.equal(
|
||||
hit("created:>2026-03", made),
|
||||
false,
|
||||
"March is not after March",
|
||||
);
|
||||
assert.equal(
|
||||
hit("created:none", profile()),
|
||||
true,
|
||||
"legacy profiles have none",
|
||||
);
|
||||
});
|
||||
|
||||
test("version comparisons run segment by segment", () => {
|
||||
assert.equal(hit("version:>140", profile()), true);
|
||||
assert.equal(hit("version:>=140.0", profile()), true);
|
||||
assert.equal(hit("version:<140", profile()), false);
|
||||
assert.equal(hit("version:>141", profile()), false);
|
||||
assert.equal(
|
||||
hit("version:>9", profile({ version: "10.0.1" })),
|
||||
true,
|
||||
"not lexical",
|
||||
);
|
||||
});
|
||||
|
||||
test("a query being typed never throws and never blanks the list", () => {
|
||||
const target = profile({ tags: ["prod"], note: 'say "hello"' });
|
||||
const halves = [
|
||||
'name:"unclosed',
|
||||
"name:",
|
||||
"tag:",
|
||||
"-",
|
||||
"!",
|
||||
":",
|
||||
'"',
|
||||
'""',
|
||||
"or",
|
||||
"or or or",
|
||||
"tag:,,,",
|
||||
"created:>",
|
||||
"created:>notadate",
|
||||
"launched:<abc",
|
||||
"tag:prod created:notadate",
|
||||
"=",
|
||||
"tag:=",
|
||||
">=<:",
|
||||
"name:>shop",
|
||||
];
|
||||
for (const raw of halves) {
|
||||
assert.doesNotThrow(() => parseProfileSearch(raw), raw);
|
||||
assert.doesNotThrow(() => hit(raw, target), raw);
|
||||
}
|
||||
assert.equal(hit('name:"unclosed', profile({ name: "unclosed" })), true);
|
||||
assert.equal(hit("tag:", target), true, "a bare field filters nothing");
|
||||
assert.equal(
|
||||
hit("tag:prod created:notadate", target),
|
||||
true,
|
||||
"bad date drops itself",
|
||||
);
|
||||
assert.equal(
|
||||
hit("name:>shop", profile()),
|
||||
false,
|
||||
"a bad operator falls to text",
|
||||
);
|
||||
assert.equal(hit("name:>shop", profile({ note: "name:>shop" })), true);
|
||||
});
|
||||
|
||||
test("unicode values compare case-insensitively", () => {
|
||||
const cyrillic = profile({ name: "Профиль Магазин", tags: ["Реклама"] });
|
||||
assert.equal(hit("магазин", cyrillic), true);
|
||||
assert.equal(hit("МАГАЗИН", cyrillic), true);
|
||||
assert.equal(hit("tag:реклама", cyrillic), true);
|
||||
assert.equal(hit("name:профиль", cyrillic), true);
|
||||
|
||||
const cjk = profile({ name: "東京プロファイル", note: "測試" });
|
||||
assert.equal(hit("東京", cjk), true);
|
||||
assert.equal(hit("note:測試", cjk), true);
|
||||
|
||||
const emoji = profile({ name: "Store 🛒 EU", tags: ["🔥 hot"] });
|
||||
assert.equal(hit("🛒", emoji), true);
|
||||
assert.equal(hit('tag:"🔥 hot"', emoji), true);
|
||||
assert.equal(hit("straße", profile({ name: "Straße Berlin" })), true);
|
||||
});
|
||||
|
||||
test("every field carries a translation key and unique tokens", () => {
|
||||
const seen = new Set();
|
||||
for (const field of PROFILE_SEARCH_FIELDS) {
|
||||
assert.match(field.labelKey, /^search\.fields\./, field.key);
|
||||
for (const token of [field.key, ...field.aliases]) {
|
||||
assert.equal(seen.has(token), false, `duplicate token ${token}`);
|
||||
seen.add(token);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,751 @@
|
||||
/**
|
||||
* The profile search grammar.
|
||||
*
|
||||
* One text box carries the whole filter, so the grammar has to survive whatever
|
||||
* is in it halfway through a keystroke: a bare word still means what it always
|
||||
* meant, and anything the parser does not recognise degrades to that bare-word
|
||||
* search instead of to an error or an empty table. `foo:bar` is free text
|
||||
* because `foo` is not a field, which is what keeps a pasted `https://x:8080`
|
||||
* or a `12:30` in a note searchable. Nothing here throws, and nothing here may
|
||||
* answer "no rows" because of syntax.
|
||||
*
|
||||
* Field names and values are ASCII slugs and are never translated, so a query
|
||||
* means the same thing in every locale; only the help panel's prose goes
|
||||
* through `t()`, keyed off the `labelKey` each field carries. The React layer
|
||||
* calls `parseProfileSearch` and `matchesProfile` and nothing else — every
|
||||
* lookup the matcher needs (a group's name for its id, which profiles are
|
||||
* running) arrives in the `ProfileSearchContext` the caller builds.
|
||||
*
|
||||
* Kept free of runtime imports so `profile-search.test.mjs` can load it
|
||||
* directly, the way `proxy-string.ts` is.
|
||||
*/
|
||||
|
||||
import type { BrowserProfile } from "@/types";
|
||||
|
||||
export type ProfileSearchFieldKind =
|
||||
| "text"
|
||||
| "tags"
|
||||
| "id"
|
||||
| "lookup"
|
||||
| "enum"
|
||||
| "boolean"
|
||||
| "date"
|
||||
| "version";
|
||||
|
||||
export interface ProfileSearchField {
|
||||
/** Canonical token; the one the help panel teaches. */
|
||||
readonly key: string;
|
||||
readonly aliases: readonly string[];
|
||||
readonly kind: ProfileSearchFieldKind;
|
||||
/** Translation key describing the field to a human. */
|
||||
readonly labelKey: string;
|
||||
/** Accepted slugs, where the set is closed. Shown in the help panel. */
|
||||
readonly values?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed field vocabulary. Closed on purpose: a token only becomes a field
|
||||
* when it is in here, so adding a short everyday word (`ip`, `url`) would
|
||||
* silently turn someone's plain-text search into a filter.
|
||||
*/
|
||||
export const PROFILE_SEARCH_FIELDS: readonly ProfileSearchField[] = [
|
||||
{ key: "name", aliases: [], kind: "text", labelKey: "search.fields.name" },
|
||||
{
|
||||
key: "tag",
|
||||
aliases: ["tags"],
|
||||
kind: "tags",
|
||||
labelKey: "search.fields.tag",
|
||||
},
|
||||
{
|
||||
key: "note",
|
||||
aliases: ["notes"],
|
||||
kind: "text",
|
||||
labelKey: "search.fields.note",
|
||||
},
|
||||
{ key: "id", aliases: [], kind: "id", labelKey: "search.fields.id" },
|
||||
{
|
||||
key: "group",
|
||||
aliases: ["folder"],
|
||||
kind: "lookup",
|
||||
labelKey: "search.fields.group",
|
||||
},
|
||||
{
|
||||
key: "proxy",
|
||||
aliases: [],
|
||||
kind: "lookup",
|
||||
labelKey: "search.fields.proxy",
|
||||
},
|
||||
{ key: "vpn", aliases: [], kind: "lookup", labelKey: "search.fields.vpn" },
|
||||
{
|
||||
key: "ext",
|
||||
aliases: ["extension"],
|
||||
kind: "lookup",
|
||||
labelKey: "search.fields.ext",
|
||||
},
|
||||
{
|
||||
key: "dns",
|
||||
aliases: [],
|
||||
kind: "enum",
|
||||
labelKey: "search.fields.dns",
|
||||
values: ["light", "normal", "pro", "pro_plus", "ultimate", "custom"],
|
||||
},
|
||||
{
|
||||
key: "os",
|
||||
aliases: [],
|
||||
kind: "enum",
|
||||
labelKey: "search.fields.os",
|
||||
values: ["macos", "windows", "linux"],
|
||||
},
|
||||
{
|
||||
key: "browser",
|
||||
aliases: [],
|
||||
kind: "text",
|
||||
labelKey: "search.fields.browser",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
aliases: [],
|
||||
kind: "enum",
|
||||
labelKey: "search.fields.status",
|
||||
values: ["running", "stopped"],
|
||||
},
|
||||
{
|
||||
key: "sync",
|
||||
aliases: [],
|
||||
kind: "enum",
|
||||
labelKey: "search.fields.sync",
|
||||
values: ["disabled", "regular", "encrypted"],
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
aliases: ["owner"],
|
||||
kind: "text",
|
||||
labelKey: "search.fields.email",
|
||||
},
|
||||
{
|
||||
key: "version",
|
||||
aliases: [],
|
||||
kind: "version",
|
||||
labelKey: "search.fields.version",
|
||||
},
|
||||
{
|
||||
key: "locked",
|
||||
aliases: ["password"],
|
||||
kind: "boolean",
|
||||
labelKey: "search.fields.locked",
|
||||
values: ["yes", "no"],
|
||||
},
|
||||
{
|
||||
key: "ephemeral",
|
||||
aliases: [],
|
||||
kind: "boolean",
|
||||
labelKey: "search.fields.ephemeral",
|
||||
values: ["yes", "no"],
|
||||
},
|
||||
{
|
||||
key: "created",
|
||||
aliases: [],
|
||||
kind: "date",
|
||||
labelKey: "search.fields.created",
|
||||
},
|
||||
{
|
||||
key: "launched",
|
||||
aliases: ["lastlaunch"],
|
||||
kind: "date",
|
||||
labelKey: "search.fields.launched",
|
||||
},
|
||||
];
|
||||
|
||||
/** Operator vocabulary, for the help panel. The token is the syntax itself. */
|
||||
export const PROFILE_SEARCH_OPERATORS: readonly {
|
||||
readonly token: string;
|
||||
readonly labelKey: string;
|
||||
}[] = [
|
||||
{ token: "-tag:ads", labelKey: "search.operators.negate" },
|
||||
{ token: 'group:"Client A"', labelKey: "search.operators.quote" },
|
||||
{ token: "tag:ads or tag:seo", labelKey: "search.operators.or" },
|
||||
{ token: "tag:ads,seo", labelKey: "search.operators.comma" },
|
||||
{ token: "tag:=prod", labelKey: "search.operators.exact" },
|
||||
{ token: "proxy:none", labelKey: "search.operators.none" },
|
||||
{ token: "created:>=2026-01-01", labelKey: "search.operators.compare" },
|
||||
];
|
||||
|
||||
/** Whole queries worth copying, for the help panel. */
|
||||
export const PROFILE_SEARCH_EXAMPLES: readonly {
|
||||
readonly query: string;
|
||||
readonly labelKey: string;
|
||||
}[] = [
|
||||
{ query: 'status:running group:"Client A"', labelKey: "search.examples.a" },
|
||||
{ query: "tag:none proxy:any", labelKey: "search.examples.b" },
|
||||
{ query: "launched:>30d -tag:archived", labelKey: "search.examples.c" },
|
||||
];
|
||||
|
||||
export type ProfileSearchOperator = "match" | "lt" | "lte" | "gt" | "gte";
|
||||
|
||||
interface FreeTextTerm {
|
||||
readonly type: "text";
|
||||
/** Already lowercased. */
|
||||
readonly value: string;
|
||||
readonly negated: boolean;
|
||||
}
|
||||
|
||||
interface FieldTerm {
|
||||
readonly type: "field";
|
||||
readonly field: ProfileSearchField;
|
||||
readonly operator: ProfileSearchOperator;
|
||||
/** Alternatives from the comma shorthand; any one matching matches. */
|
||||
readonly values: readonly string[];
|
||||
readonly negated: boolean;
|
||||
/** `=value`: whole-value match rather than substring. */
|
||||
readonly exact: boolean;
|
||||
/** The value was quoted, so `none` and `any` are literal text. */
|
||||
readonly quoted: boolean;
|
||||
}
|
||||
|
||||
export type ProfileSearchTerm = FreeTextTerm | FieldTerm;
|
||||
|
||||
export interface ParsedProfileSearch {
|
||||
/** AND across the groups, OR inside each one. */
|
||||
readonly groups: readonly (readonly ProfileSearchTerm[])[];
|
||||
/** Nothing left to filter on, so every profile matches. */
|
||||
readonly isEmpty: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileSearchContext {
|
||||
/** Group id to the name the table shows for it. Same for the three below. */
|
||||
readonly groupNames: ReadonlyMap<string, string>;
|
||||
readonly proxyNames: ReadonlyMap<string, string>;
|
||||
readonly vpnNames: ReadonlyMap<string, string>;
|
||||
readonly extensionGroupNames: ReadonlyMap<string, string>;
|
||||
readonly runningProfiles: ReadonlySet<string>;
|
||||
/** Epoch ms the relative dates count back from. Defaults to the wall clock. */
|
||||
readonly now?: number;
|
||||
}
|
||||
|
||||
const FIELD_BY_TOKEN: ReadonlyMap<string, ProfileSearchField> = new Map(
|
||||
PROFILE_SEARCH_FIELDS.flatMap((field) =>
|
||||
[field.key, ...field.aliases].map(
|
||||
(token) => [token, field] as [string, ProfileSearchField],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const RESERVED_NONE = "none";
|
||||
const RESERVED_ANY = "any";
|
||||
const RESERVED_NEVER = "never";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const DURATION_UNITS: Readonly<Record<string, number>> = {
|
||||
h: 3_600_000,
|
||||
d: DAY_MS,
|
||||
w: 7 * DAY_MS,
|
||||
m: 30 * DAY_MS,
|
||||
y: 365 * DAY_MS,
|
||||
};
|
||||
|
||||
interface QueryChar {
|
||||
readonly c: string;
|
||||
readonly quoted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits on whitespace outside double quotes. An unclosed quote runs to the end
|
||||
* of the input instead of being rejected: the query is re-parsed on every
|
||||
* keystroke, so `name:"unclosed` is a query being typed, not a mistake. Each
|
||||
* character remembers whether it was quoted, which is what keeps a separator
|
||||
* inside quotes (`group:"Acme, Inc"`) literal.
|
||||
*/
|
||||
function tokenize(raw: string): QueryChar[][] {
|
||||
const tokens: QueryChar[][] = [];
|
||||
let current: QueryChar[] = [];
|
||||
let quoted = false;
|
||||
for (const c of raw) {
|
||||
if (c === '"') {
|
||||
quoted = !quoted;
|
||||
continue;
|
||||
}
|
||||
if (!quoted && /\s/.test(c)) {
|
||||
if (current.length > 0) {
|
||||
tokens.push(current);
|
||||
current = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current.push({ c, quoted });
|
||||
}
|
||||
if (current.length > 0) tokens.push(current);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function textOf(chars: readonly QueryChar[]): string {
|
||||
let out = "";
|
||||
for (const ch of chars) out += ch.c;
|
||||
return out;
|
||||
}
|
||||
|
||||
function hasQuoted(chars: readonly QueryChar[]): boolean {
|
||||
return chars.some((ch) => ch.quoted);
|
||||
}
|
||||
|
||||
/** Splits on an unquoted separator, dropping the empty pieces. */
|
||||
function splitUnquoted(chars: readonly QueryChar[], sep: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
for (const ch of chars) {
|
||||
if (ch.c === sep && !ch.quoted) {
|
||||
if (current.length > 0) parts.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += ch.c;
|
||||
}
|
||||
if (current.length > 0) parts.push(current);
|
||||
return parts;
|
||||
}
|
||||
|
||||
interface SeparatorToken {
|
||||
readonly token: string;
|
||||
readonly operator: ProfileSearchOperator;
|
||||
readonly negates: boolean;
|
||||
}
|
||||
|
||||
/** Longest first, so `>=` is never read as `>` followed by a stray `=`. */
|
||||
const SEPARATORS: readonly SeparatorToken[] = [
|
||||
{ token: ">=", operator: "gte", negates: false },
|
||||
{ token: "<=", operator: "lte", negates: false },
|
||||
{ token: "!=", operator: "match", negates: true },
|
||||
{ token: ":", operator: "match", negates: false },
|
||||
{ token: ">", operator: "gt", negates: false },
|
||||
{ token: "<", operator: "lt", negates: false },
|
||||
];
|
||||
|
||||
function separatorAt(
|
||||
chars: readonly QueryChar[],
|
||||
index: number,
|
||||
): SeparatorToken | null {
|
||||
for (const candidate of SEPARATORS) {
|
||||
let hit = true;
|
||||
for (let i = 0; i < candidate.token.length; i++) {
|
||||
const ch = chars[index + i];
|
||||
if (!ch || ch.quoted || ch.c !== candidate.token[i]) {
|
||||
hit = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hit) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Comparisons only mean something where the values are ordered. */
|
||||
function acceptsComparison(field: ProfileSearchField): boolean {
|
||||
return field.kind === "date" || field.kind === "version";
|
||||
}
|
||||
|
||||
function freeText(value: string, negated: boolean): FreeTextTerm | null {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
return { type: "text", value: trimmed.toLowerCase(), negated };
|
||||
}
|
||||
|
||||
function buildTerm(chars: readonly QueryChar[]): ProfileSearchTerm | null {
|
||||
let negated = false;
|
||||
let body = chars;
|
||||
const first = body[0];
|
||||
if (
|
||||
body.length > 1 &&
|
||||
first &&
|
||||
!first.quoted &&
|
||||
(first.c === "-" || first.c === "!")
|
||||
) {
|
||||
negated = true;
|
||||
body = body.slice(1);
|
||||
}
|
||||
|
||||
let found: { at: number; token: SeparatorToken } | null = null;
|
||||
for (let i = 0; i < body.length && !found; i++) {
|
||||
if (body[i].quoted) continue;
|
||||
const token = separatorAt(body, i);
|
||||
if (token) found = { at: i, token };
|
||||
}
|
||||
if (!found || found.at === 0) return freeText(textOf(body), negated);
|
||||
|
||||
const name = textOf(body.slice(0, found.at)).toLowerCase();
|
||||
const field = FIELD_BY_TOKEN.get(name);
|
||||
// An unrecognised name is never an error: it is somebody's note holding a
|
||||
// URL, and returning zero rows for it would be the worst possible answer.
|
||||
if (!field) return freeText(textOf(body), negated);
|
||||
|
||||
let operator = found.token.operator;
|
||||
let value = body.slice(found.at + found.token.token.length);
|
||||
// `created:>=2026-01-01` writes the comparison after the colon; `created>=...`
|
||||
// writes it instead of one. Both reach the same term.
|
||||
if (found.token.token === ":") {
|
||||
const inner = separatorAt(value, 0);
|
||||
if (inner && inner.operator !== "match") {
|
||||
operator = inner.operator;
|
||||
value = value.slice(inner.token.length);
|
||||
}
|
||||
}
|
||||
if (operator !== "match" && !acceptsComparison(field)) {
|
||||
return freeText(textOf(body), negated);
|
||||
}
|
||||
if (value.length === 0) return null;
|
||||
if (found.token.negates) negated = !negated;
|
||||
|
||||
let exact = false;
|
||||
const lead = value[0];
|
||||
if (lead && !lead.quoted && lead.c === "=") {
|
||||
exact = true;
|
||||
value = value.slice(1);
|
||||
if (value.length === 0) return null;
|
||||
}
|
||||
|
||||
const quoted = hasQuoted(value);
|
||||
const values = (quoted ? [textOf(value)] : splitUnquoted(value, ",")).map(
|
||||
(v) => v.toLowerCase(),
|
||||
);
|
||||
if (values.length === 0) return null;
|
||||
|
||||
if (field.kind === "date") {
|
||||
const usable = values.filter((v) => parseDateValue(v) !== null);
|
||||
// A date that does not parse drops its own term and leaves the rest of the
|
||||
// query running, rather than filtering everything away.
|
||||
if (usable.length === 0) return null;
|
||||
return {
|
||||
type: "field",
|
||||
field,
|
||||
operator,
|
||||
values: usable,
|
||||
negated,
|
||||
exact,
|
||||
quoted,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "field",
|
||||
field,
|
||||
operator,
|
||||
values,
|
||||
negated,
|
||||
exact,
|
||||
quoted,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns raw input into AND-ed groups of OR-ed terms. Total: every input, valid
|
||||
* or not, produces a result, and an input with nothing usable in it produces an
|
||||
* empty one that matches every profile.
|
||||
*/
|
||||
export function parseProfileSearch(raw: string): ParsedProfileSearch {
|
||||
const groups: ProfileSearchTerm[][] = [];
|
||||
let pendingOr = false;
|
||||
|
||||
for (const chars of tokenize(raw)) {
|
||||
if (!hasQuoted(chars) && textOf(chars).toLowerCase() === "or") {
|
||||
// A dangling `or` at either end simply has nothing to join.
|
||||
pendingOr = groups.length > 0;
|
||||
continue;
|
||||
}
|
||||
const term = buildTerm(chars);
|
||||
if (!term) continue;
|
||||
const last = groups[groups.length - 1];
|
||||
if (pendingOr && last) {
|
||||
last.push(term);
|
||||
} else {
|
||||
groups.push([term]);
|
||||
}
|
||||
pendingOr = false;
|
||||
}
|
||||
|
||||
return { groups, isEmpty: groups.length === 0 };
|
||||
}
|
||||
|
||||
type DateValue =
|
||||
| { readonly kind: "relative"; readonly durationMs: number }
|
||||
| {
|
||||
readonly kind: "absolute";
|
||||
readonly startMs: number;
|
||||
readonly endMs: number;
|
||||
}
|
||||
| { readonly kind: "never" }
|
||||
| { readonly kind: "any" };
|
||||
|
||||
const RELATIVE_PATTERN = /^(\d+)([hdwmy])$/;
|
||||
const ABSOLUTE_PATTERN = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/;
|
||||
|
||||
/** `null` for anything that is not a date, which is how a term gets dropped. */
|
||||
function parseDateValue(value: string): DateValue | null {
|
||||
if (value === RESERVED_NEVER || value === RESERVED_NONE) {
|
||||
return { kind: "never" };
|
||||
}
|
||||
if (value === RESERVED_ANY) return { kind: "any" };
|
||||
|
||||
const relative = RELATIVE_PATTERN.exec(value);
|
||||
if (relative) {
|
||||
const amount = Number.parseInt(relative[1], 10);
|
||||
const unit = DURATION_UNITS[relative[2]];
|
||||
if (unit === undefined) return null;
|
||||
return { kind: "relative", durationMs: amount * unit };
|
||||
}
|
||||
|
||||
const absolute = ABSOLUTE_PATTERN.exec(value);
|
||||
if (!absolute) return null;
|
||||
const year = Number.parseInt(absolute[1], 10);
|
||||
if (absolute[2] === undefined) {
|
||||
return {
|
||||
kind: "absolute",
|
||||
startMs: new Date(year, 0, 1).getTime(),
|
||||
endMs: new Date(year + 1, 0, 1).getTime(),
|
||||
};
|
||||
}
|
||||
const month = Number.parseInt(absolute[2], 10);
|
||||
if (month < 1 || month > 12) return null;
|
||||
if (absolute[3] === undefined) {
|
||||
return {
|
||||
kind: "absolute",
|
||||
startMs: new Date(year, month - 1, 1).getTime(),
|
||||
endMs: new Date(year, month, 1).getTime(),
|
||||
};
|
||||
}
|
||||
const day = Number.parseInt(absolute[3], 10);
|
||||
const start = new Date(year, month - 1, day);
|
||||
// February 31st parses as March 3rd unless the roll-over is caught here.
|
||||
if (start.getMonth() !== month - 1 || start.getDate() !== day) return null;
|
||||
return {
|
||||
kind: "absolute",
|
||||
startMs: start.getTime(),
|
||||
endMs: new Date(year, month - 1, day + 1).getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
function matchesDate(
|
||||
seconds: number | undefined,
|
||||
operator: ProfileSearchOperator,
|
||||
value: string,
|
||||
now: number,
|
||||
): boolean {
|
||||
const parsed = parseDateValue(value);
|
||||
if (!parsed) return false;
|
||||
if (parsed.kind === "never") return !seconds;
|
||||
if (parsed.kind === "any") return Boolean(seconds);
|
||||
if (!seconds) return false;
|
||||
const ts = seconds * 1000;
|
||||
|
||||
if (parsed.kind === "relative") {
|
||||
// Read the way the question is asked, not the way the timestamps compare:
|
||||
// `launched:<7d` is "inside the last 7 days" and `launched:>30d` is "not
|
||||
// launched for over 30 days", which is the query an operator hunting cold
|
||||
// profiles actually wants.
|
||||
const threshold = now - parsed.durationMs;
|
||||
switch (operator) {
|
||||
case "gt":
|
||||
return ts < threshold;
|
||||
case "gte":
|
||||
return ts <= threshold;
|
||||
default:
|
||||
return ts >= threshold;
|
||||
}
|
||||
}
|
||||
|
||||
switch (operator) {
|
||||
case "lt":
|
||||
return ts < parsed.startMs;
|
||||
case "lte":
|
||||
return ts < parsed.endMs;
|
||||
case "gt":
|
||||
return ts >= parsed.endMs;
|
||||
case "gte":
|
||||
return ts >= parsed.startMs;
|
||||
default:
|
||||
return ts >= parsed.startMs && ts < parsed.endMs;
|
||||
}
|
||||
}
|
||||
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const left = a.split(".");
|
||||
const right = b.split(".");
|
||||
const length = Math.max(left.length, right.length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
const l = Number.parseInt(left[i] ?? "0", 10);
|
||||
const r = Number.parseInt(right[i] ?? "0", 10);
|
||||
const ln = Number.isNaN(l) ? 0 : l;
|
||||
const rn = Number.isNaN(r) ? 0 : r;
|
||||
if (ln !== rn) return ln < rn ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parseBoolean(value: string): boolean | null {
|
||||
if (value === "yes" || value === "true" || value === "1") return true;
|
||||
if (value === "no" || value === "false" || value === "0") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
interface FieldValue {
|
||||
/** The profile has something here, even if its name cannot be resolved. */
|
||||
readonly present: boolean;
|
||||
/** Lowercased text to compare against. */
|
||||
readonly candidates: readonly string[];
|
||||
}
|
||||
|
||||
function lookupValue(
|
||||
id: string | undefined,
|
||||
names: ReadonlyMap<string, string>,
|
||||
): FieldValue {
|
||||
if (!id) return { present: false, candidates: [] };
|
||||
const name = names.get(id);
|
||||
return {
|
||||
present: true,
|
||||
candidates: name ? [name.toLowerCase()] : [],
|
||||
};
|
||||
}
|
||||
|
||||
function fieldValue(
|
||||
profile: BrowserProfile,
|
||||
field: ProfileSearchField,
|
||||
ctx: ProfileSearchContext,
|
||||
): FieldValue {
|
||||
const one = (value: string | undefined | null): FieldValue =>
|
||||
value
|
||||
? { present: true, candidates: [value.toLowerCase()] }
|
||||
: { present: false, candidates: [] };
|
||||
|
||||
switch (field.key) {
|
||||
case "name":
|
||||
return one(profile.name);
|
||||
case "note":
|
||||
return one(profile.note);
|
||||
case "browser":
|
||||
return one(profile.browser);
|
||||
case "version":
|
||||
return one(profile.version);
|
||||
case "email":
|
||||
return one(profile.created_by_email);
|
||||
case "id":
|
||||
return { present: true, candidates: [profile.id.toLowerCase()] };
|
||||
case "tag": {
|
||||
const tags = profile.tags ?? [];
|
||||
return {
|
||||
present: tags.length > 0,
|
||||
candidates: tags.map((tag) => tag.toLowerCase()),
|
||||
};
|
||||
}
|
||||
case "group":
|
||||
return lookupValue(profile.group_id, ctx.groupNames);
|
||||
case "proxy":
|
||||
return lookupValue(profile.proxy_id, ctx.proxyNames);
|
||||
case "vpn":
|
||||
return lookupValue(profile.vpn_id, ctx.vpnNames);
|
||||
case "ext":
|
||||
return lookupValue(profile.extension_group_id, ctx.extensionGroupNames);
|
||||
case "dns":
|
||||
return one(profile.dns_blocklist);
|
||||
case "os":
|
||||
return one(profile.host_os ?? profile.wayfern_config?.os);
|
||||
case "sync":
|
||||
return one(profile.sync_mode ?? "Disabled");
|
||||
case "status":
|
||||
return one(ctx.runningProfiles.has(profile.id) ? "running" : "stopped");
|
||||
default:
|
||||
return { present: false, candidates: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function matchesFieldValue(term: FieldTerm, value: string, actual: FieldValue) {
|
||||
if (!term.quoted && !term.exact) {
|
||||
if (value === RESERVED_NONE) return !actual.present;
|
||||
if (value === RESERVED_ANY) return actual.present;
|
||||
}
|
||||
if (term.field.kind === "id") {
|
||||
return actual.candidates.some((candidate) =>
|
||||
term.exact ? candidate === value : candidate.startsWith(value),
|
||||
);
|
||||
}
|
||||
if (term.field.kind === "enum") {
|
||||
// A prefix is enough, so `status:run` works while the user is still typing.
|
||||
return actual.candidates.some((candidate) =>
|
||||
term.exact ? candidate === value : candidate.startsWith(value),
|
||||
);
|
||||
}
|
||||
return actual.candidates.some((candidate) =>
|
||||
term.exact ? candidate === value : candidate.includes(value),
|
||||
);
|
||||
}
|
||||
|
||||
function matchesFieldTerm(
|
||||
profile: BrowserProfile,
|
||||
term: FieldTerm,
|
||||
ctx: ProfileSearchContext,
|
||||
): boolean {
|
||||
const field = term.field;
|
||||
|
||||
if (field.kind === "boolean") {
|
||||
const actual =
|
||||
field.key === "locked"
|
||||
? profile.password_protected === true
|
||||
: profile.ephemeral === true;
|
||||
return term.values.some((value) => parseBoolean(value) === actual);
|
||||
}
|
||||
|
||||
if (field.kind === "date") {
|
||||
const now = ctx.now ?? Date.now();
|
||||
const seconds =
|
||||
field.key === "created" ? profile.created_at : profile.last_launch;
|
||||
return term.values.some((value) =>
|
||||
matchesDate(seconds, term.operator, value, now),
|
||||
);
|
||||
}
|
||||
|
||||
if (field.kind === "version" && term.operator !== "match") {
|
||||
return term.values.some((value) => {
|
||||
const order = compareVersions(profile.version, value);
|
||||
switch (term.operator) {
|
||||
case "lt":
|
||||
return order < 0;
|
||||
case "lte":
|
||||
return order <= 0;
|
||||
case "gt":
|
||||
return order > 0;
|
||||
default:
|
||||
return order >= 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const actual = fieldValue(profile, field, ctx);
|
||||
return term.values.some((value) => matchesFieldValue(term, value, actual));
|
||||
}
|
||||
|
||||
/**
|
||||
* What a bare word searches: the same three fields the box has always covered,
|
||||
* plus the id, so the trimmed id the table shows can be pasted straight back in.
|
||||
*/
|
||||
function matchesFreeText(profile: BrowserProfile, value: string): boolean {
|
||||
if (profile.name.toLowerCase().includes(value)) return true;
|
||||
if (profile.note?.toLowerCase().includes(value)) return true;
|
||||
if (profile.tags?.some((tag) => tag.toLowerCase().includes(value))) {
|
||||
return true;
|
||||
}
|
||||
return profile.id.toLowerCase().startsWith(value);
|
||||
}
|
||||
|
||||
export function matchesProfile(
|
||||
profile: BrowserProfile,
|
||||
parsed: ParsedProfileSearch,
|
||||
ctx: ProfileSearchContext,
|
||||
): boolean {
|
||||
for (const group of parsed.groups) {
|
||||
const hit = group.some((term) => {
|
||||
const matched =
|
||||
term.type === "text"
|
||||
? matchesFreeText(profile, term.value)
|
||||
: matchesFieldTerm(profile, term, ctx);
|
||||
return term.negated ? !matched : matched;
|
||||
});
|
||||
if (!hit) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -88,6 +88,24 @@ export interface SyncSettings {
|
||||
sync_token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of `check_sync_server_connection`. Files upload straight to the
|
||||
* storage host named in the presigned URL rather than through the sync server,
|
||||
* so a healthy server is not evidence that sync works: `storage_reachable`
|
||||
* false means every transfer will fail at connect.
|
||||
*
|
||||
* `null` means "not known", which is not the same as false — a server that
|
||||
* predates `/readyz`, or a cloud deployment that withholds its storage host,
|
||||
* discloses nothing to probe.
|
||||
*/
|
||||
export interface SyncServerCheck {
|
||||
server_reachable: boolean;
|
||||
storage_ready: boolean | null;
|
||||
storage_endpoint: string | null;
|
||||
storage_reachable: boolean | null;
|
||||
storage_error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability/limit set derived from the plan by the backend. Features are gated
|
||||
* on these flags instead of a single "is paid?" check, so a plan like "solo"
|
||||
@@ -395,6 +413,8 @@ export interface WayfernConfig {
|
||||
randomize_fingerprint_on_launch?: boolean; // Generate new fingerprint on every launch
|
||||
os?: WayfernOS; // Operating system for fingerprint generation
|
||||
geo_proxy_signature?: string; // Internal: routing the fingerprint's location was computed for
|
||||
identity_id?: string; // Internal: UUID the device is derived from on browsers with the identity API
|
||||
identity_baseline?: string; // Internal: derived fingerprint before edits, diffed to recover overrides
|
||||
}
|
||||
|
||||
// Wayfern fingerprint config - matches the C++ FingerprintData structure
|
||||
@@ -630,6 +650,53 @@ export interface CookieCopyResult {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// Cookie paste types. Unlike the copy types above these are serialized with
|
||||
// `rename_all = "camelCase"`, so the field names differ from the Rust structs.
|
||||
export type CookieIssueSeverity = "error" | "warning" | "info";
|
||||
|
||||
export interface CookieIssue {
|
||||
code: string;
|
||||
severity: CookieIssueSeverity;
|
||||
source: string | null;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
export type CookiePasteFormat = "json" | "netscape" | "nameValue";
|
||||
|
||||
export type CookieWriteMode = "merge" | "replaceMatchingSites";
|
||||
|
||||
/** Carries no `value`: the value is the credential and never leaves Rust. */
|
||||
export interface PastedCookiePreview {
|
||||
name: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
isSecure: boolean;
|
||||
isHttpOnly: boolean;
|
||||
sameSite: number;
|
||||
}
|
||||
|
||||
export interface CookieAnalysis {
|
||||
format: CookiePasteFormat | null;
|
||||
cookies: PastedCookiePreview[];
|
||||
issues: CookieIssue[];
|
||||
siteRequired: boolean;
|
||||
expiredCount: number;
|
||||
/** `null` when the store cannot be read, which is not the same as zero. */
|
||||
replaceDeleteCount: number | null;
|
||||
clearsOnClose: boolean;
|
||||
/** A `{"code":…}` string for `translateBackendError`, or `null` to proceed. */
|
||||
blockedBy: string | null;
|
||||
}
|
||||
|
||||
export interface CookiePasteImportResult {
|
||||
added: number;
|
||||
overwritten: number;
|
||||
deleted: number;
|
||||
skipped: number;
|
||||
issues: CookieIssue[];
|
||||
}
|
||||
|
||||
// Proxy import/export types
|
||||
export interface ProxyExportData {
|
||||
version: string;
|
||||
@@ -751,3 +818,16 @@ export interface PreLaunchChecks {
|
||||
exit_measurement_unreliable: boolean;
|
||||
consent_token: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What happened when the user asked Donut to become the default browser.
|
||||
*
|
||||
* macOS and Linux let a program make the change itself, so the answer there is
|
||||
* always "set". Windows reserves the final choice for its own settings page:
|
||||
* the app registers itself, Windows Settings opens, and the user finishes the
|
||||
* job. Treating that case as plain success is how the button used to report a
|
||||
* change that had not happened.
|
||||
*/
|
||||
export type SetDefaultBrowserOutcome =
|
||||
| { status: "set" }
|
||||
| { status: "awaitingSystemSettings" };
|
||||
|
||||
Reference in New Issue
Block a user