Compare commits

..
8 Commits
Author SHA1 Message Date
zhom 11b130df46 chore: linting 2026-08-08 23:50:12 +04:00
zhom b8e5b4f4e6 chore: update pnpm 2026-08-08 22:29:53 +04:00
zhom d80e127cd3 chore: switch to ai-inference v3 and fail workflows on 410 2026-08-08 22:29:01 +04:00
zhom e11967509d chore: version bump 2026-08-08 22:28:35 +04:00
zhom 6d9a44faad fix: prevent settings page from crashing on some systems 2026-08-08 20:36:51 +04:00
zhom f8532be8af refactor: update logic and locks around vpn extensions 2026-08-08 19:27:39 +04:00
github-actions[bot]GitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
70a8deb7eb chore: update flake.nix for v0.29.0 [skip ci] (#542)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-08 14:11:50 +00:00
github-actions[bot]GitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
b89f002c1d docs: update CHANGELOG.md and README.md for v0.29.0 [skip ci] (#541)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-08 14:11:35 +00:00
44 changed files with 1315 additions and 394 deletions
+6 -1
View File
@@ -30,4 +30,9 @@ messages:
### Documentation
### Dependencies
### Developer Experience
model: openai/gpt-4.1
# `auto` lets the Copilot CLI pick. Deliberately not a pinned model id: it is
# the only value valid on every Copilot plan (Free and Student get auto
# selection only), and it cannot go stale the way `openai/gpt-4.1` did when
# GitHub Models was retired on 2026-07-30 and took both of these workflows
# down with it.
model: auto
@@ -20,4 +20,9 @@ messages:
{{commits}}
Format: one short opening sentence, a blank line, then bullets starting with "- " (one per line). Nothing else.
model: openai/gpt-4.1
# `auto` lets the Copilot CLI pick. Deliberately not a pinned model id: it is
# the only value valid on every Copilot plan (Free and Student get auto
# selection only), and it cannot go stale the way `openai/gpt-4.1` did when
# GitHub Models was retired on 2026-07-30 and took both of these workflows
# down with it.
model: auto
+17 -2
View File
@@ -96,7 +96,8 @@ jobs:
-d "$PAYLOAD" || echo "000")
if [ "$STATUS" != "200" ]; then
echo "::warning::GitHub Models returned HTTP $STATUS; treating as compliant"
echo "::error::GitHub Models returned HTTP $STATUS; treating as compliant"
printf '%s\n' "inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json
exit 0
fi
@@ -107,7 +108,8 @@ jobs:
# to a compliant result so a flaky model never closes a legitimate issue.
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
echo "::warning::Model returned non-JSON; treating as compliant"
echo "::error::Model returned non-JSON; treating as compliant"
printf '%s\n' "model returned output that was not JSON" >> /tmp/ai-degraded
echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json
fi
echo "Compliance response validated"
@@ -145,3 +147,16 @@ jobs:
run: |
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
gh issue close "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --reason "not planned"
# The steps above deliberately degrade rather than block: an inference
# outage must never close a contributor's issue or flag their pull
# request. But a run that skipped the check it exists to perform has not
# succeeded, and reporting green hides that the automation is dead.
- name: Fail if the AI check did not actually run
if: always()
run: |
if [ -f /tmp/ai-degraded ]; then
echo "::error::This check degraded to a no-op and its result was not verified:"
sed 's/^/ - /' /tmp/ai-degraded
exit 1
fi
+40 -6
View File
@@ -257,7 +257,8 @@ jobs:
if [ "$STATUS" = "200" ]; then
jq -r '.choices[0].message.content // empty' /tmp/triage-response.json > /tmp/triage-raw.txt || : > /tmp/triage-raw.txt
else
echo "::warning::GitHub Models returned HTTP $STATUS for triage"
echo "::error::GitHub Models returned HTTP $STATUS for triage"
printf '%s\n' "triage inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
: > /tmp/triage-raw.txt
fi
@@ -266,7 +267,8 @@ jobs:
# Fall back to a safe classification when the response is not JSON.
if ! jq -e . /tmp/triage.json >/dev/null 2>&1; then
echo "::warning::Triage returned non-JSON; using fallback classification"
echo "::error::Triage returned non-JSON; using fallback classification"
printf '%s\n' "triage returned output that was not JSON" >> /tmp/ai-degraded
jq -n '{
language: "en",
classification: "bug-in-scope",
@@ -436,7 +438,8 @@ jobs:
-d "$PAYLOAD" || echo "000")
if [ "$STATUS" != "200" ]; then
echo "::warning::GitHub Models returned HTTP $STATUS; skipping the triage comment"
echo "::error::GitHub Models returned HTTP $STATUS; skipping the triage comment"
printf '%s\n' "composer inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
echo "has_comment=false" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -444,7 +447,8 @@ jobs:
jq -r '.choices[0].message.content // empty' /tmp/compose-response.json > /tmp/ai-comment.txt || : > /tmp/ai-comment.txt
if [ ! -s /tmp/ai-comment.txt ]; then
echo "::warning::Composer returned empty response; skipping the triage comment"
echo "::error::Composer returned empty response; skipping the triage comment"
printf '%s\n' "composer returned an empty response" >> /tmp/ai-degraded
echo "has_comment=false" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -482,6 +486,20 @@ jobs:
run: |
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
# The steps above deliberately degrade rather than block: an inference
# outage must never close a contributor's issue or flag their pull
# request. But a run that skipped the check it exists to perform has not
# succeeded, and reporting green hides that the automation is dead.
- name: Fail if the AI check did not actually run
if: always()
run: |
if [ -f /tmp/ai-degraded ]; then
echo "::error::This check degraded to a no-op and its result was not verified:"
sed 's/^/ - /' /tmp/ai-degraded
exit 1
fi
analyze-pr:
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
@@ -619,7 +637,8 @@ jobs:
-d "$PAYLOAD" || echo "000")
if [ "$STATUS" != "200" ]; then
echo "::warning::GitHub Models returned HTTP $STATUS; skipping the review comment"
echo "::error::GitHub Models returned HTTP $STATUS; skipping the review comment"
printf '%s\n' "PR review inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
echo "has_comment=false" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -627,7 +646,8 @@ jobs:
jq -r '.choices[0].message.content // empty' /tmp/pr-response.json > /tmp/ai-comment.txt || : > /tmp/ai-comment.txt
if [ ! -s /tmp/ai-comment.txt ]; then
echo "::warning::AI response was empty; skipping the review comment"
echo "::error::AI response was empty; skipping the review comment"
printf '%s\n' "PR review returned an empty response" >> /tmp/ai-degraded
echo "has_comment=false" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -642,6 +662,20 @@ jobs:
run: |
gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
# The steps above deliberately degrade rather than block: an inference
# outage must never close a contributor's issue or flag their pull
# request. But a run that skipped the check it exists to perform has not
# succeeded, and reporting green hides that the automation is dead.
- name: Fail if the AI check did not actually run
if: always()
run: |
if [ -f /tmp/ai-degraded ]; then
echo "::error::This check degraded to a no-op and its result was not verified:"
sed 's/^/ - /' /tmp/ai-degraded
exit 1
fi
opencode-command:
if: |
github.repository == 'zhom/donutbrowser' &&
+13 -3
View File
@@ -22,7 +22,7 @@ on:
permissions:
contents: read
models: read
copilot-requests: write
jobs:
notify:
@@ -123,17 +123,27 @@ jobs:
echo "previous-tag=${PREV_TAG}" >> "$GITHUB_OUTPUT"
echo "Collected $(wc -l < commits.txt) commits between ${PREV_TAG} and ${TAG}."
# The Copilot CLI is not preinstalled on GitHub-hosted runners, and
# ai-inference v3 shells out to it.
- name: Install Copilot CLI
if: steps.gate.outputs.skip != 'true'
run: npm install -g @github/copilot
- name: Generate summary with AI
id: ai
if: steps.gate.outputs.skip != 'true'
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
uses: actions/ai-inference@2c43c91ae16266ca159d311430343c67a5ffa222 # v3
with:
prompt-file: .github/prompts/telegram-release-summary.prompt.yml
input: |
version: ${{ steps.tag.outputs.tag }}
file_input: |
commits: ./commits.txt
max-tokens: 1024
env:
# The Copilot CLI reads its credential from the environment; the
# workflow token carries it under the `copilot-requests` permission
# granted above, so no PAT is needed.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Post release announcement to Telegram
if: steps.gate.outputs.skip != 'true'
+17 -2
View File
@@ -134,7 +134,8 @@ jobs:
-d "$PAYLOAD" || echo "000")
if [ "$STATUS" != "200" ]; then
echo "::warning::GitHub Models returned HTTP $STATUS; treating as compliant"
echo "::error::GitHub Models returned HTTP $STATUS; treating as compliant"
printf '%s\n' "inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
echo '{"compliant": true, "violations": []}' > /tmp/result.json
exit 0
fi
@@ -146,7 +147,8 @@ jobs:
# The deterministic trailer scan still stands on its own below.
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
echo "::warning::Model returned non-JSON; treating as compliant"
echo "::error::Model returned non-JSON; treating as compliant"
printf '%s\n' "model returned output that was not JSON" >> /tmp/ai-degraded
echo '{"compliant": true, "violations": []}' > /tmp/result.json
fi
echo "Policy response validated"
@@ -205,3 +207,16 @@ jobs:
run: |
gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
gh pr close "$PR_NUMBER" --repo "$GITHUB_REPOSITORY"
# The steps above deliberately degrade rather than block: an inference
# outage must never close a contributor's issue or flag their pull
# request. But a run that skipped the check it exists to perform has not
# succeeded, and reporting green hides that the automation is dead.
- name: Fail if the AI check did not actually run
if: always()
run: |
if [ -f /tmp/ai-degraded ]; then
echo "::error::This check degraded to a no-op and its result was not verified:"
sed 's/^/ - /' /tmp/ai-degraded
exit 1
fi
+13 -3
View File
@@ -8,7 +8,7 @@ on:
permissions:
contents: write
models: read
copilot-requests: write
jobs:
generate-release-notes:
@@ -79,17 +79,27 @@ jobs:
echo "commits-file=commits.txt" >> $GITHUB_OUTPUT
echo "changes-file=changes.txt" >> $GITHUB_OUTPUT
# The Copilot CLI is not preinstalled on GitHub-hosted runners, and
# ai-inference v3 shells out to it.
- name: Install Copilot CLI
if: steps.get-release.outputs.is-prerelease == 'false'
run: npm install -g @github/copilot
- name: Generate release notes with AI
id: generate-notes
if: steps.get-release.outputs.is-prerelease == 'false'
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
uses: actions/ai-inference@2c43c91ae16266ca159d311430343c67a5ffa222 # v3
with:
prompt-file: .github/prompts/release-notes.prompt.yml
input: |
version: ${{ steps.get-previous-tag.outputs.current-tag }}
file_input: |
commits: ./commits.txt
max-tokens: 4096
env:
# The Copilot CLI reads its credential from the environment; the
# workflow token carries it under the `copilot-requests` permission
# granted above, so no PAT is needed.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update release with generated notes
if: steps.get-release.outputs.is-prerelease == 'false'
+67
View File
@@ -1,6 +1,73 @@
# Changelog
## v0.29.0 (2026-08-08)
### Features
- prevent launch with inconsistent geodata
- cookie bot
- remote sessions
- xray support
- mass import via gui, api, and mcp
- add Turkish (tr) language support
### Bug Fixes
- properly handle x-amz-meta-updated-at
- improve UI interactions and page consistency
### Refactoring
- cleanup
- cleanup
- improve proxy lifetime management
- cleanup
- remote cleanup
- cleanup cloud sync
- cleanup
- harden tests
- block windows app update if the browser is running
- ui refresh
### Documentation
- update CHANGELOG.md and README.md for v0.29.0 [skip ci] (#539)
- contrib-readme-action has updated readme
- contrib-readme-action has updated readme
### Maintenance
- chore: version bump
- ci(deps): bump the github-actions group with 3 updates (#538)
- chore: linting
- chore: linting
- chore: linting
- chore: ci
- chore: upload sidecars to cdn
- chore: linting
- ci(deps): bump the github-actions group with 4 updates
- chore: linting
- chore: disable e2e in ci
- chore: linting
- chore: linting
- chore: ai compliance
- chore: linting
- ci(deps): bump the github-actions group across 1 directory with 3 updates (#514)
- chore: linting
- chore: linting
- chore: add cross-platform webdriver tests
- ci(deps): bump the github-actions group with 2 updates
- chore: update flake.nix for v0.28.2 [skip ci] (#501)
### Other
- deps(deps): bump next from 16.2.10 to 16.2.11 (#515)
- refactors: animations cleanup
- restore settings redirect
- fix group create translation key
## v0.29.0 (2026-08-08)
### Features
+4
View File
@@ -12,3 +12,7 @@ extend-exclude = [
[default.extend-words]
DBE = "DBE"
nd = "nd"
[default.extend-identifiers]
# Chrome Web Store extension name in the known-VPN list.
VeePN = "VeePN"
+1 -1
View File
@@ -1785,7 +1785,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.28.2"
version = "0.29.0"
dependencies = [
"aes 0.9.1",
"aes-gcm 0.11.0",
+82 -2
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readdir, readFile, stat } from "node:fs/promises";
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
@@ -270,6 +270,78 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
"a consent token is only minted when a cached mismatch is blocking",
);
// Extension detection, against manifests written where Chromium puts
// them. The three cases are the whole point of the classifier: a real VPN
// is named as one, a known VPN with an unrevealing name is caught by its
// id, and a download manager holding the same `proxy` permission is
// reported as a capability and never as a VPN.
// `DONUTBROWSER_DATA_ROOT` puts the data dir at <dataRoot>/data, so this
// is app_dirs::profiles_dir() plus the layout Chromium itself uses.
const extensionsDir = path.join(
app.dataRoot,
"data",
"profiles",
profile.id,
"profile",
"Default",
"Extensions",
);
const seedExtension = async (id, version, manifest) => {
const dir = path.join(extensionsDir, id, `${version}_0`);
await mkdir(dir, { recursive: true });
await writeFile(
path.join(dir, "manifest.json"),
JSON.stringify(manifest),
);
};
const IDM_ID = "ngpampappnmepgilojfohadhhmbhlaek";
const HOTSPOT_SHIELD_ID = "nlbejmccbhkncgokjcmghpfloaajcffj";
const NAMED_VPN_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
await seedExtension(IDM_ID, "6.43.1", {
name: "IDM Integration Module",
version: "6.43.1",
description: "Download files with Internet Download Manager",
permissions: ["downloads", "storage", "proxy", "nativeMessaging"],
});
await seedExtension(HOTSPOT_SHIELD_ID, "10.0.0", {
name: "Hotspot Shield",
version: "10.0.0",
permissions: ["proxy"],
});
await seedExtension(NAMED_VPN_ID, "1.0.0", {
name: "Turbo VPN Free",
version: "1.0.0",
permissions: ["proxy"],
});
const withExtensions = await app.invoke("get_profile_pre_launch_checks", {
profileId: profile.id,
});
const detected = new Map(
withExtensions.vpn_extensions.map((item) => [item.key, item]),
);
assert.equal(detected.size, 3, "every seeded extension must be reported");
assert.equal(detected.get(`crx:${NAMED_VPN_ID}`).confidence, "confirmed");
assert.equal(
detected.get(`crx:${HOTSPOT_SHIELD_ID}`).confidence,
"confirmed",
"a known VPN id must be named even when its name gives nothing away",
);
assert.equal(
detected.get(`crx:${IDM_ID}`).confidence,
"capability",
"a download manager holding the proxy permission is not a VPN",
);
assert.ok(
detected.get(`crx:${IDM_ID}`).proxy_control,
"it does still hold the permission, which is why it is listed at all",
);
assert.equal(
withExtensions.exit_measurement_unreliable,
true,
"a proxy-capable extension makes the exit measurement a caveat",
);
// Acknowledgements are per-profile and must be accepted for both kinds.
await app.invoke("ack_launch_gate", {
profileId: profile.id,
@@ -279,8 +351,16 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
await app.invoke("ack_launch_gate", {
profileId: profile.id,
ackFingerprint: true,
ackExtensionKeys: [],
ackExtensionKeys: [`crx:${IDM_ID}`],
});
const afterAck = await app.invoke("get_profile_pre_launch_checks", {
profileId: profile.id,
});
assert.deepEqual(
afterAck.vpn_extensions.map((item) => item.key).sort(),
[`crx:${HOTSPOT_SHIELD_ID}`, `crx:${NAMED_VPN_ID}`].sort(),
"an acknowledged extension stops being reported, the others do not",
);
assert.match(
await app.invokeError("get_profile_pre_launch_checks", {
profileId: "00000000-0000-0000-0000-000000000000",
+5 -5
View File
@@ -96,17 +96,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.28.2";
releaseVersion = "0.29.0";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.AppImage";
hash = "sha256-+CqHiPMg4oczNiPg+MC6jvp0CUcK4kb5yeyk+QDbAWY=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.0/Donut_0.29.0_amd64.AppImage";
hash = "sha256-CPPiB7kOvlBJRZhcZAjnIIxKptwUqZOgsYdYBBJhu5M=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.AppImage";
hash = "sha256-HodokW2ySIpdpW7Hyqpwsm8whQ0hHldlSg11Sl1UW3k=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.0/Donut_0.29.0_aarch64.AppImage";
hash = "sha256-qzeAfe4PjsVAyDgsuIDTKlqXw+Bwqk3E5APfkLbl2oY=";
}
else
null;
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.29.0",
"version": "0.29.1",
"type": "module",
"scripts": {
"predev": "pnpm licenses:generate",
@@ -111,7 +111,7 @@
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.3"
},
"packageManager": "pnpm@11.10.0",
"packageManager": "pnpm@11.20.0",
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css}": [
"biome check --fix"
@@ -1,5 +1,5 @@
diff --git a/dist/commonjs/index.d.ts b/dist/commonjs/index.d.ts
index f3e2de9d87e1ce462517e49f35733bed8bdf85af..6c84d87835182d0670981dc15f488c2a7d061c98 100644
index f3e2de9d87e1ce462517e49f35733bed8bdf85af..7a19917a209b84b30938957ea67d4ad60dd748c5 100644
--- a/dist/commonjs/index.d.ts
+++ b/dist/commonjs/index.d.ts
@@ -5,4 +5,5 @@ export type BraceExpansionOptions = {
@@ -8,18 +8,20 @@ index f3e2de9d87e1ce462517e49f35733bed8bdf85af..6c84d87835182d0670981dc15f488c2a
export declare function expand(str: string, options?: BraceExpansionOptions): string[];
+export default expand;
//# sourceMappingURL=index.d.ts.map
\ No newline at end of file
diff --git a/dist/commonjs/index.js b/dist/commonjs/index.js
index be9df86be09c7655787a65c55ae6da01858894c3..071ad97532f30155cb56d7f2662fa99122ca628a 100644
index 869a6bee23807b9f01c18c99ab8e952b4b242f97..cd8fa65b1a1521aa0b27b3e661797763b8365138 100644
--- a/dist/commonjs/index.js
+++ b/dist/commonjs/index.js
@@ -260,4 +260,5 @@ function expand_(str, max, maxLength, isTop) {
@@ -286,4 +286,5 @@ function expand_(str, max, maxLength, isTop) {
}
return acc;
}
+module.exports = Object.assign(expand, exports);
//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts
index f3e2de9d87e1ce462517e49f35733bed8bdf85af..6c84d87835182d0670981dc15f488c2a7d061c98 100644
index f3e2de9d87e1ce462517e49f35733bed8bdf85af..7a19917a209b84b30938957ea67d4ad60dd748c5 100644
--- a/dist/esm/index.d.ts
+++ b/dist/esm/index.d.ts
@@ -5,4 +5,5 @@ export type BraceExpansionOptions = {
@@ -28,11 +30,12 @@ index f3e2de9d87e1ce462517e49f35733bed8bdf85af..6c84d87835182d0670981dc15f488c2a
export declare function expand(str: string, options?: BraceExpansionOptions): string[];
+export default expand;
//# sourceMappingURL=index.d.ts.map
\ No newline at end of file
diff --git a/dist/esm/index.js b/dist/esm/index.js
index 6dc0392fc0feedb811e63d70a30be2736af17a3a..81ea182fa5dbc3c60fa4cec4cb549fa256a903e4 100644
index fd68f57029207ac1bcafe7fb1c14ad5305b3ffa4..f3ef09ac8ad02d3fde8150e7f64f40ac874a47e3 100644
--- a/dist/esm/index.js
+++ b/dist/esm/index.js
@@ -256,4 +256,5 @@ function expand_(str, max, maxLength, isTop) {
@@ -282,4 +282,5 @@ function expand_(str, max, maxLength, isTop) {
}
return acc;
}
+29 -28
View File
@@ -9,21 +9,22 @@ overrides:
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
postcss@<8.5.18: '>=8.5.18'
fast-xml-parser@<5.7.0: '>=5.7.2'
fast-uri@<3.1.2: '>=3.1.2 <4'
fast-uri@<3.1.5: '>=3.1.5 <4'
fast-xml-builder@<1.2.0: '>=1.2.0'
qs@>=6.11.1 <6.15.2: '>=6.15.2'
js-cookie@<3.0.7: '>=3.0.7'
nanoid@<3.3.17: '>=3.3.17 <4'
fast-uri@>=4.0.0 <4.1.1: '>=4.1.1 <5'
multer@>=2.0.0 <2.2.0: '>=2.2.0'
form-data@>=4.0.0 <4.0.6: '>=4.0.6'
js-yaml@<3.15.0: '>=3.15.0 <4'
js-yaml@>=4.0.0 <4.3.0: '>=4.3.0 <5'
js-yaml@<3.15.1: '>=3.15.1 <4'
js-yaml@>=4.0.0 <4.3.1: '>=4.3.1 <5'
'@babel/core@<7.29.6': '>=7.29.6 <8'
brace-expansion@<5.0.8: 5.0.8
brace-expansion@<5.0.9: 5.0.9
sharp@<0.35.0: '>=0.35.0 <0.36'
patchedDependencies:
brace-expansion@5.0.8: 6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e
brace-expansion@5.0.9: bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208
importers:
@@ -2852,8 +2853,8 @@ packages:
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
brace-expansion@5.0.8:
resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
browserslist@4.28.4:
@@ -3340,8 +3341,8 @@ packages:
fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
fast-uri@3.1.4:
resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==}
fast-uri@3.1.5:
resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
fb-watchman@2.0.2:
resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==}
@@ -3773,12 +3774,12 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@3.15.0:
resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==}
js-yaml@3.15.1:
resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==}
hasBin: true
js-yaml@4.3.0:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
js-yaml@4.3.1:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
jsesc@3.1.0:
@@ -4093,8 +4094,8 @@ packages:
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
engines: {node: ^18.17.0 || >=20.5.0}
nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
nanoid@3.3.17:
resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -6040,7 +6041,7 @@ snapshots:
camelcase: 5.3.1
find-up: 4.1.0
get-package-type: 0.1.0
js-yaml: 3.15.0
js-yaml: 3.15.1
resolve-from: 5.0.0
'@istanbuljs/schema@0.1.6': {}
@@ -7857,14 +7858,14 @@ snapshots:
ajv@8.18.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.4
fast-uri: 3.1.5
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.4
fast-uri: 3.1.5
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
@@ -8038,7 +8039,7 @@ snapshots:
bowser@2.14.1: {}
brace-expansion@5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e):
brace-expansion@5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208):
dependencies:
balanced-match: 4.0.4
@@ -8232,7 +8233,7 @@ snapshots:
cosmiconfig@8.3.6(typescript@5.9.3):
dependencies:
import-fresh: 3.3.1
js-yaml: 4.3.0
js-yaml: 4.3.1
parse-json: 5.2.0
path-type: 4.0.0
optionalDependencies:
@@ -8480,7 +8481,7 @@ snapshots:
fast-safe-stringify@2.1.1: {}
fast-uri@3.1.4: {}
fast-uri@3.1.5: {}
fb-watchman@2.0.2:
dependencies:
@@ -9102,12 +9103,12 @@ snapshots:
js-tokens@4.0.0: {}
js-yaml@3.15.0:
js-yaml@3.15.1:
dependencies:
argparse: 1.0.10
esprima: 4.0.1
js-yaml@4.3.0:
js-yaml@4.3.1:
dependencies:
argparse: 2.0.1
@@ -9332,15 +9333,15 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e)
brace-expansion: 5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208)
minimatch@3.1.5:
dependencies:
brace-expansion: 5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e)
brace-expansion: 5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208)
minimatch@9.0.9:
dependencies:
brace-expansion: 5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e)
brace-expansion: 5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208)
minimist@1.2.8: {}
@@ -9371,7 +9372,7 @@ snapshots:
mute-stream@2.0.0: {}
nanoid@3.3.16: {}
nanoid@3.3.17: {}
napi-postinstall@0.3.4: {}
@@ -9535,7 +9536,7 @@ snapshots:
postcss@8.5.23:
dependencies:
nanoid: 3.3.16
nanoid: 3.3.17
picocolors: 1.1.1
source-map-js: 1.2.1
+6 -5
View File
@@ -24,17 +24,18 @@ overrides:
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
postcss@<8.5.18: '>=8.5.18'
fast-xml-parser@<5.7.0: '>=5.7.2'
fast-uri@<3.1.2: '>=3.1.2 <4'
fast-uri@<3.1.5: '>=3.1.5 <4'
fast-xml-builder@<1.2.0: '>=1.2.0'
qs@>=6.11.1 <6.15.2: '>=6.15.2'
js-cookie@<3.0.7: '>=3.0.7'
nanoid@<3.3.17: '>=3.3.17 <4'
fast-uri@>=4.0.0 <4.1.1: '>=4.1.1 <5'
multer@>=2.0.0 <2.2.0: '>=2.2.0'
form-data@>=4.0.0 <4.0.6: '>=4.0.6'
js-yaml@<3.15.0: '>=3.15.0 <4'
js-yaml@>=4.0.0 <4.3.0: '>=4.3.0 <5'
js-yaml@<3.15.1: '>=3.15.1 <4'
js-yaml@>=4.0.0 <4.3.1: '>=4.3.1 <5'
'@babel/core@<7.29.6': '>=7.29.6 <8'
brace-expansion@<5.0.8: 5.0.8
brace-expansion@<5.0.9: 5.0.9
sharp@<0.35.0: '>=0.35.0 <0.36'
allowBuilds:
@@ -97,4 +98,4 @@ minimumReleaseAgeExclude:
- '@aws-sdk/token-providers@3.1081.0'
patchedDependencies:
brace-expansion@5.0.8: patches/brace-expansion@5.0.8.patch
brace-expansion@5.0.9: patches/brace-expansion@5.0.9.patch
+1 -1
View File
@@ -1797,7 +1797,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.29.0"
version = "0.29.1"
dependencies = [
"aes 0.9.1",
"aes-gcm 0.11.0",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.29.0"
version = "0.29.1"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
+45 -14
View File
@@ -121,6 +121,46 @@ function extractArchive(archive, destinationDir, windowsTarget) {
};
}
/// Attempts for the archive download. A release asset fetch is a network call
/// on every CI job, and a single transport error ("fetch failed") has taken
/// whole builds down. Retrying is safe because the checksum below is verified
/// on every attempt, so a truncated or substituted archive still cannot pass.
const DOWNLOAD_ATTEMPTS = 3;
async function downloadVerifiedArchive(url, archive, expectedSha256) {
let lastError;
for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to download Xray-core (${response.status} ${response.statusText})`,
);
}
writeFileSync(archive, Buffer.from(await response.arrayBuffer()));
const actual = sha256(archive);
if (actual !== expectedSha256) {
throw new Error(
`Xray-core checksum mismatch: expected ${expectedSha256}, got ${actual}`,
);
}
return;
} catch (error) {
lastError = error;
if (attempt < DOWNLOAD_ATTEMPTS) {
console.warn(
`Xray-core download attempt ${attempt} failed (${error.message}); retrying`,
);
await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
}
}
}
throw lastError;
}
export async function downloadXray(target = requestedTarget()) {
const asset = XRAY_ASSETS[target];
if (!asset) {
@@ -157,20 +197,11 @@ export async function downloadXray(target = requestedTarget()) {
const scratch = mkdtempSync(join(tmpdir(), "donut-xray-"));
try {
const archive = join(scratch, basename(asset.name));
const response = await fetch(xrayDownloadUrl(asset.name));
if (!response.ok) {
throw new Error(
`Failed to download Xray-core (${response.status} ${response.statusText})`,
);
}
writeFileSync(archive, Buffer.from(await response.arrayBuffer()));
const actual = sha256(archive);
if (actual !== asset.sha256) {
throw new Error(
`Xray-core checksum mismatch: expected ${asset.sha256}, got ${actual}`,
);
}
await downloadVerifiedArchive(
xrayDownloadUrl(asset.name),
archive,
asset.sha256,
);
const extracted = extractArchive(archive, scratch, windowsTarget);
if (!existsSync(extracted.binary) || !existsSync(extracted.license)) {
+20 -2
View File
@@ -18,8 +18,13 @@ impl DefaultBrowser {
#[cfg(target_os = "windows")]
return windows::is_default_browser();
// 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 desktop takes to answer. The Settings page polls this on a timer.
#[cfg(target_os = "linux")]
return linux::is_default_browser();
return blocking(linux::is_default_browser).await;
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
Err("Unsupported platform".to_string())
@@ -32,14 +37,27 @@ impl DefaultBrowser {
#[cfg(target_os = "windows")]
return windows::set_as_default_browser();
// Same reasoning, and this one additionally sleeps 500ms before verifying.
#[cfg(target_os = "linux")]
return linux::set_as_default_browser();
return blocking(linux::set_as_default_browser).await;
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
Err("Unsupported platform".to_string())
}
}
/// Run blocking work off the async runtime's worker threads.
#[cfg(target_os = "linux")]
async fn blocking<T, F>(work: F) -> Result<T, String>
where
F: FnOnce() -> Result<T, String> + Send + 'static,
T: Send + 'static,
{
tokio::task::spawn_blocking(work)
.await
.map_err(|e| format!("Default browser check did not run: {e}"))?
}
#[cfg(target_os = "macos")]
mod macos {
use core_foundation::base::OSStatus;
+16 -18
View File
@@ -264,23 +264,16 @@ pub async fn enforce_fingerprint_gate(
return Ok(());
}
// Only now is the extension scan worth its disk walk. A confirmed
// proxy-permission extension can redirect the browser's traffic away from the
// upstream we just measured, so the measurement describes an exit the browser
// may not take. Report it, but do not hard-block on a number known to be
// unreliable.
let measurement_unreliable =
vpn_extension_detect::has_confirmed(&vpn_extension_detect::scan_profile(profile));
if matches!(gate, FingerprintGate::Advisory) || measurement_unreliable {
// Automation is the only caller allowed past a measured mismatch, because it
// has no dialog to answer. A proxy-capable extension in the profile does NOT
// earn the same pass: it makes the measurement less trustworthy, and a route
// that might be worse than measured is a reason for more scrutiny, not less.
// Waiving the block on it also meant any download manager holding Chromium's
// `proxy` permission silently disarmed the gate for good.
if matches!(gate, FingerprintGate::Advisory) {
log::warn!(
"Fingerprint gate: {} launching with a {} exit mismatch ({})",
"Fingerprint gate: {} launching with a known exit mismatch ({})",
profile.name,
if measurement_unreliable {
"unverifiable"
} else {
"known"
},
result.mismatches.join(", ")
);
if let Err(e) = crate::events::emit("fingerprint-consistency-warning", &result) {
@@ -304,8 +297,9 @@ pub struct PreLaunchChecks {
/// True when the enforcing gate will still probe during the launch, so the
/// UI can say the check is not finished rather than implying it passed.
pub exit_probe_pending: bool,
/// A confirmed proxy-permission extension is present, so any exit
/// measurement describes a route the browser may not take.
/// An extension holding the `proxy` permission is present, so any exit
/// measurement describes a route the browser may not take. Informational
/// only — it never relaxes the block.
pub exit_measurement_unreliable: bool,
/// Present only when a cached mismatch is already blocking, so "launch
/// anyway" can proceed without a second round trip.
@@ -325,6 +319,10 @@ fn load_profile(profile_id: &str) -> Result<BrowserProfile, String> {
pub async fn get_profile_pre_launch_checks(profile_id: String) -> Result<PreLaunchChecks, String> {
let profile = load_profile(&profile_id)?;
// The setting suppresses the extension report entirely, which is safe
// precisely because nothing enforcing depends on it: the scan feeds the
// dialog's warning and the "measurement may be unreliable" note, never the
// decision to block.
let scan = if extension_warning_disabled() {
vpn_extension_detect::ExtensionScan {
extensions: Vec::new(),
@@ -344,7 +342,7 @@ pub async fn get_profile_pre_launch_checks(profile_id: String) -> Result<PreLaun
})
.cloned()
.collect();
let exit_measurement_unreliable = vpn_extension_detect::has_confirmed(&scan);
let exit_measurement_unreliable = vpn_extension_detect::has_proxy_control(&scan);
let disabled = gate_disabled();
let key = fingerprint_consistency::exit_cache_key(&profile);
+30 -12
View File
@@ -382,34 +382,52 @@ pub fn schedule_pull(app_handle: tauri::AppHandle, profile_id: String) {
});
}
/// Serialises every test that can reach [`STORE`], wherever it lives.
///
/// `remote_session`'s tests drive session transitions through `note_running`
/// and `note_ended`, so they mutate this module's global store too — with the
/// same `p1`/`p2` fixture ids. Two mutexes meant the two groups could interleave
/// and clobber each other, which showed up as an intermittent failure in the
/// suite guarding a data-loss bug.
#[cfg(test)]
pub(crate) static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Take the store lock and start from an empty store. Callers must hold the
/// returned guard for the whole test.
#[cfg(test)]
pub(crate) fn lock_for_test() -> std::sync::MutexGuard<'static, ()> {
let lock = TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*STORE
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Store::new());
lock
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
/// Serialises the tests.
///
/// `TEST_DATA_DIR` is thread-local but [`STORE`] is process-global, so two
/// tests running at once would share one store while pointing at different
/// directories. That fails intermittently, which is the worst way for a test
/// guarding a data-loss bug to fail.
static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Point the store at a scratch directory and start it empty.
///
/// Everything returned must outlive the test body: dropping the guard
/// restores the real data directory, and a test that let it drop early would
/// write a gate file into the developer's own app data.
/// write a gate file into the developer's own app data. `TEST_DATA_DIR` is
/// thread-local but [`STORE`] is process-global, so [`lock_for_test`] is what
/// keeps two tests from sharing one store while pointing at different
/// directories.
fn isolated() -> (
tempfile::TempDir,
crate::app_dirs::TestDirGuard,
std::sync::MutexGuard<'static, ()>,
) {
let lock = TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let lock = lock_for_test();
let dir = tempfile::TempDir::new().expect("a scratch directory");
let guard = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
// Re-taken after the data dir is redirected, so nothing loads from the
// real one.
*STORE
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Store::new());
+8
View File
@@ -1569,6 +1569,14 @@ mod tests {
let _guard = INDEX_TESTS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
// Applying a session transition also drives `remote_handoff`: it mutates
// that module's process-global store and persists the launch gate to the
// data directory. Its lock keeps the two test groups from clobbering each
// other's `p1`/`p2` fixtures, and the scratch directory keeps the gate file
// out of the developer's own app data.
let _handoff = crate::remote_handoff::lock_for_test();
let dir = tempfile::TempDir::new().expect("a scratch directory");
let _data_dir = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
with_index(|map| map.clear());
with_endpoints(|map| map.clear());
INDEX_AUTHORITATIVE.store(false, Ordering::SeqCst);
@@ -10,8 +10,8 @@ use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use super::rules::{
classify, keyword_hit, lookup_message, manifest_str, message_placeholder_key, signal_labels,
signals_from_manifest, version_dir_sort_key, DetectedVpnExtension,
classify, lookup_message, manifest_str, message_placeholder_key, signal_labels,
signals_from_manifest, version_dir_sort_key, vpn_keyword_hit, DetectedVpnExtension,
};
/// Upper bound on extension directories walked per profile. A launch must not
@@ -262,8 +262,8 @@ fn detect_in_version_dir(crx_id: &str, version_dir: &Path) -> Option<DetectedVpn
});
let signals = signals_from_manifest(&manifest);
let keyword = keyword_hit(&name, description.as_deref());
let confidence = classify(&signals, keyword)?;
let keyword = vpn_keyword_hit(&name, description.as_deref());
let confidence = classify(Some(crx_id), &signals, keyword)?;
Some(DetectedVpnExtension {
key: format!("crx:{crx_id}"),
@@ -271,7 +271,8 @@ fn detect_in_version_dir(crx_id: &str, version_dir: &Path) -> Option<DetectedVpn
version: manifest_str(&manifest, "version"),
source: "browser".to_string(),
confidence: confidence.to_string(),
signals: signal_labels(&signals, keyword),
proxy_control: signals.proxy_permission,
signals: signal_labels(Some(crx_id), &signals, keyword),
})
}
@@ -489,6 +490,50 @@ mod tests {
assert_eq!(out[0].name, CRX_ID, "never show a raw __MSG_ placeholder");
}
#[test]
fn scan_reports_a_proxy_holding_download_manager_as_a_capability() {
// End-to-end shape of the false positive that prompted the audit: the
// extension must still be surfaced (it really can change the proxy) but
// never as a VPN.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
&root
.join("Default")
.join("Extensions")
.join("ngpampappnmepgilojfohadhhmbhlaek")
.join("6.43.1_0")
.join("manifest.json"),
r#"{"name":"IDM Integration Module","version":"6.43.1","description":"Download files with Internet Download Manager","permissions":["downloads","storage","proxy","nativeMessaging"]}"#,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out.len(), 1);
assert_eq!(out[0].confidence, "capability");
assert!(out[0].proxy_control);
}
#[test]
fn scan_confirms_a_known_vpn_whose_name_gives_nothing_away() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(
&root
.join("Default")
.join("Extensions")
.join("nlbejmccbhkncgokjcmghpfloaajcffj")
.join("10.0.0_0")
.join("manifest.json"),
r#"{"name":"Hotspot Shield","version":"10.0.0","permissions":["proxy"]}"#,
);
let mut out = Vec::new();
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
assert_eq!(out.len(), 1);
assert_eq!(out[0].confidence, "confirmed");
}
#[test]
fn scan_ignores_an_ordinary_extension() {
let tmp = tempfile::tempdir().unwrap();
+24 -11
View File
@@ -7,6 +7,11 @@
//! except Donut cannot observe it from the outside — hence a launch-time
//! warning rather than a measurement.
//!
//! That permission is a capability, not an identity. Chromium exposes no
//! read-only variant of it, so a download manager replicating the browser's
//! route for its own transfers declares exactly what a VPN hijacking it
//! declares. The two are reported as different things — see `rules::classify`.
//!
//! Two sources, deliberately both: Donut-managed extensions live in the app's
//! own store and are handed to Chromium via `--load-extension` from *outside*
//! the profile directory, while extensions the user installed from the Web
@@ -17,7 +22,7 @@ mod rules;
// `message_placeholder_key`/`lookup_message` are shared with
// `extension_manager`, which resolves the same placeholders out of a zip.
use rules::{classify, keyword_hit, manifest_str, signal_labels, signals_from_manifest};
use rules::{classify, manifest_str, signal_labels, signals_from_manifest, vpn_keyword_hit};
pub use rules::{lookup_message, message_placeholder_key, DetectedVpnExtension};
use serde::{Deserialize, Serialize};
@@ -90,8 +95,11 @@ fn scan_donut_extensions(profile: &BrowserProfile, out: &mut Vec<DetectedVpnExte
});
let signals = signals_from_manifest(&manifest);
let keyword = keyword_hit(&name, description.as_deref());
let Some(confidence) = classify(&signals, keyword) else {
let keyword = vpn_keyword_hit(&name, description.as_deref());
// A Donut-managed extension is stored under Donut's own uuid, not the Web
// Store id the known-VPN list is keyed on, so it is classified on what its
// manifest says about itself.
let Some(confidence) = classify(None, &signals, keyword) else {
continue;
};
@@ -101,7 +109,8 @@ fn scan_donut_extensions(profile: &BrowserProfile, out: &mut Vec<DetectedVpnExte
version: manifest_str(&manifest, "version").or_else(|| ext.version.clone()),
source: "donut".to_string(),
confidence: confidence.to_string(),
signals: signal_labels(&signals, keyword),
proxy_control: signals.proxy_permission,
signals: signal_labels(None, &signals, keyword),
});
}
}
@@ -144,9 +153,8 @@ pub fn scan_profile(profile: &BrowserProfile) -> ExtensionScan {
// Collapse only exact duplicates of the same extension. `key` is the real
// identity (`donut:<uuid>` / `crx:<id>`); name+version is not, and two
// distinct extensions sharing a display name would silently fold into one
// dropping a `confirmed` detection would then flip `has_confirmed()` and stop
// the gate treating its own exit measurement as unreliable.
// distinct extensions sharing a display name would silently fold into one,
// hiding a real detection behind an unrelated namesake.
let mut seen = HashSet::new();
extensions.retain(|e| seen.insert(e.key.clone()));
@@ -156,8 +164,13 @@ pub fn scan_profile(profile: &BrowserProfile) -> ExtensionScan {
}
}
/// True when at least one detection is `confirmed` — the extension holds the
/// `proxy` permission and can actually redirect the browser's traffic.
pub fn has_confirmed(scan: &ExtensionScan) -> bool {
scan.extensions.iter().any(|e| e.confidence == "confirmed")
/// True when at least one extension holds the `proxy` permission outright, so
/// it can redirect the browser's traffic without asking for anything further.
///
/// Informational: it tells the user an exit measurement may describe a route
/// the browser will not take. It deliberately does not relax the gate — a
/// measurement that might be wrong is a reason for more scrutiny, not less,
/// and this signal is true for every download manager on the machine.
pub fn has_proxy_control(scan: &ExtensionScan) -> bool {
scan.extensions.iter().any(|e| e.proxy_control)
}
+260 -66
View File
@@ -8,21 +8,67 @@
use serde::{Deserialize, Serialize};
/// Substrings that corroborate a request-blocking extension being a VPN.
/// Matched case-insensitively against name + description.
const KEYWORDS: &[&str] = &[
"vpn",
"proxy",
"tunnel",
"unblock",
"wireguard",
"shadowsocks",
"socks",
/// Chrome Web Store ids of extensions whose whole purpose is routing the
/// browser somewhere else. Sorted, so membership is a binary search.
///
/// This list is what lets a VPN with an unrevealing name — "Hotspot Shield"
/// says nothing about what it does — be named as one instead of appearing as
/// an anonymous holder of the proxy permission. Every id was verified by
/// downloading the extension and reading its manifest; a wrong id is worse
/// than a missing one, because a stale list only ever loses recall while a
/// wrong one accuses the wrong extension.
const KNOWN_VPN_EXTENSION_IDS: &[&str] = &[
"adlpodnneegcnbophopdmhedicjbcgco", // Troywell VPN
"ailoabdmgclmfmhdagmlohpjlbpffblp", // Surfshark
"akcocjjpkmlniicdeemdceeajlmoabhg", // 1VPN
"apbcbecdpjefgklcokinpapmmdekecah", // Ninja VPN
"bihmplhobchoageeokmgbdihknkjbknd", // Touch VPN (delisted 2025, still installed in old profiles)
"blapeiihifiknfmceddkceklnpopgclm", // Proxy Switcher Pro
"bnlofglpdlboacepdieejiecfbfpmhlb", // Turbo VPN
"dookpfaalaaappcdneeahomimbllocnb", // FoxyProxy Basic
"eppiocemhmnlbhjplcgkofciiegomcon", // Urban VPN
"fcfhplploccackoneaefokcmbjfbkenj", // 1clickVPN
"fdcgdnkidjaadafnichfpabhfomcebme", // ZenMate (delisted 2025)
"ffbkglfijbcbgblgflchnbphjdllaogb", // CyberGhost
"fgddmllnllkalaagkghckoinaemmogpe", // ExpressVPN
"fjoaledfpmneenckfbpdfhkmimnjocfa", // NordVPN
"gcknhkkoolaabfmlnjonogaaifnjlfnp", // FoxyProxy
"gdpehpfhegefkjelaifkdbppjbhilaom", // Proxy-Cheap Proxy Manager
"gjakohbhfclfjmhhlenfdkldieofkpjl", // IPRoyal Proxy Manager
"gjknjjomckknofjidppipffbpoekiipm", // Betternet
"gkojfkhlekighikafcpjkiklfbnlmeio", // Hola VPN
"hnmpcagpplmpfojmgmnngilcnanddlhb", // Windscribe
"jaoafpkngncfpfggjefnekilbkcpjdgp", // uVPN
"jedieiamjmoflcknjdjhpieklepfglin", // FastestVPN
"jpadbaildllggkcgibilkeacpcodailn", // Planet VPN lite
"jplgfhpmjnbigmhklmmbgecoobifkmpa", // Proton VPN
"jplnlifepflhkbkgonidnobkakhmpnmh", // Private Internet Access
"kgepmkaldicdcljckhamnhkigddnbcbd", // PACify Proxy Manager
"kpiecbcckbofpmkkkdibbllpinceiihk", // DotVPN
"majdfhpaihoncoakbjgbdhglocklcgno", // VeePN
"nbcojefnccbanplpoffopkoepjmhgdgh", // Hoxx VPN
"nlbejmccbhkncgokjcmghpfloaajcffj", // Hotspot Shield
"ohjocgmpmlfahafbipehkhbaacoemojp", // hide.me Proxy
"omdakjcmkglenbhjadbccaookpfjihpa", // TunnelBear
"omghfjlpggmjjaagoclmmobgdodcjboh", // Browsec
"onnfghpihccifgojkpnnncpagjcdbjod", // Proxy Switcher and Manager
"oofgbpoabipfcfjapgnbbjjaenockbdp", // SetupVPN
"padekgcemlokbadohgkifijomclgjgif", // Proxy SwitchyOmega
"pphgdbgldlmicfdkhondlafkiomnelnk", // 1ClickVPN Proxy
];
/// Matched as a whole token rather than a substring — too short to be safe
/// inside other words ("warped", "warpaint").
const TOKEN_KEYWORDS: &[&str] = &["warp"];
/// Terms specific enough to name a VPN wherever they appear, including in a
/// 132-character manifest description.
const STRONG_KEYWORDS: &[&str] = &["vpn", "wireguard", "shadowsocks", "openvpn"];
/// Terms that only mean "VPN" in a product's *name*. In a description they are
/// ordinary English — "no proxy setup required", "carpal tunnel", "unblock
/// right click" — and matching them there is where the noise comes from.
const NAME_ONLY_KEYWORDS: &[&str] = &["proxy", "unblock"];
/// Matched as whole tokens rather than substrings, and in the name only. Too
/// short to be safe inside other words ("tussocks", "tunnelling").
const NAME_TOKEN_KEYWORDS: &[&str] = &["socks", "socks5", "tunnel"];
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DetectedVpnExtension {
@@ -32,8 +78,14 @@ pub struct DetectedVpnExtension {
pub version: Option<String>,
/// `"donut"` (managed by Donut) or `"browser"` (installed inside the profile).
pub source: String,
/// `"confirmed"` or `"likely"`.
/// `"confirmed"` and `"likely"` are claims that this IS a VPN/proxy tool.
/// `"capability"` claims only that it *could* change the proxy.
pub confidence: String,
/// Whether the manifest holds Chromium's `proxy` permission outright, so the
/// extension can call `chrome.proxy.settings.set` without asking again.
/// Separate from `confidence`: a download manager reading the browser's
/// proxy declares the identical permission as a VPN hijacking it.
pub proxy_control: bool,
/// Why it matched, for the dialog's detail line.
pub signals: Vec<String>,
}
@@ -90,50 +142,93 @@ pub fn signals_from_manifest(manifest: &serde_json::Value) -> ManifestSignals {
}
}
pub fn keyword_hit(name: &str, description: Option<&str>) -> bool {
let mut haystack = name.to_lowercase();
if let Some(d) = description {
haystack.push(' ');
haystack.push_str(&d.to_lowercase());
}
if KEYWORDS.iter().any(|k| haystack.contains(k)) {
return true;
}
haystack
.split(|c: char| !c.is_alphanumeric())
.any(|token| TOKEN_KEYWORDS.contains(&token))
/// True when this is the id of an extension known to route browser traffic.
pub fn is_known_vpn_extension(extension_id: &str) -> bool {
KNOWN_VPN_EXTENSION_IDS.binary_search(&extension_id).is_ok()
}
/// Classify an extension from its manifest signals.
fn has_token(haystack: &str, tokens: &[&str]) -> bool {
haystack
.split(|c: char| !c.is_alphanumeric())
.any(|token| tokens.contains(&token))
}
/// Does the extension describe itself as a VPN or proxy tool?
///
/// The `proxy` permission is the only signal that *proves* the capability: it
/// is what Chromium requires to call `chrome.proxy`, and it stays in
/// `permissions` under both manifest versions because it is an API permission,
/// not a host pattern.
/// The name is weighted far more heavily than the description, because that is
/// where the evidence actually lives: a VPN vendor puts "VPN" in the name — it
/// is how the store surfaces them — while a description is 132 characters of
/// ordinary prose in which "proxy", "tunnel" and "unblock" are all innocent.
/// Matching those three against descriptions is what flags carpal-tunnel
/// reminders, right-click unblockers, and tools whose pitch is that they need
/// *no* proxy setup.
pub fn vpn_keyword_hit(name: &str, description: Option<&str>) -> bool {
let name = name.to_lowercase();
if STRONG_KEYWORDS.iter().any(|k| name.contains(k))
|| NAME_ONLY_KEYWORDS.iter().any(|k| name.contains(k))
|| has_token(&name, NAME_TOKEN_KEYWORDS)
{
return true;
}
description
.map(str::to_lowercase)
.is_some_and(|d| STRONG_KEYWORDS.iter().any(|k| d.contains(k)))
}
/// Classify an extension from its id, manifest signals and self-description.
///
/// The request-blocking tier additionally requires a keyword, and that
/// corroboration is not optional: `declarativeNetRequest` plus `<all_urls>`
/// describes every content blocker in the ecosystem, so without it the warning
/// fires on uBlock Origin — which would teach users to dismiss the dialog on
/// sight, destroying the value of the mismatch block that shares it.
pub fn classify(signals: &ManifestSignals, keyword: bool) -> Option<&'static str> {
if signals.proxy_permission {
/// Two different questions are answered here, and fusing them is what made an
/// ordinary download manager get reported as a VPN. Chromium has no read-only
/// variant of the `proxy` permission: `chrome.proxy.settings.get()` and
/// `.set()` sit behind the same manifest string, so an extension replicating
/// the browser's proxy for its own transfers declares exactly what a VPN
/// hijacking it declares. The permission therefore proves a *capability* and
/// nothing more; naming something a VPN needs separate evidence — a known id,
/// or the extension saying so itself.
///
/// The request-blocking tier's keyword requirement is not optional either:
/// `declarativeNetRequest` plus `<all_urls>` describes every content blocker in
/// the ecosystem, so without it the warning fires on uBlock Origin — which
/// would teach users to dismiss the dialog on sight, destroying the value of
/// the mismatch block that shares it.
///
/// An `optional_permissions` entry the user has never granted is deliberately
/// not a capability at all: the extension cannot call `chrome.proxy` until it
/// asks and is allowed.
pub fn classify(
extension_id: Option<&str>,
signals: &ManifestSignals,
keyword: bool,
) -> Option<&'static str> {
if extension_id.is_some_and(is_known_vpn_extension) {
return Some("confirmed");
}
if signals.optional_proxy_permission {
return Some("likely");
if keyword {
if signals.proxy_permission {
return Some("confirmed");
}
if signals.optional_proxy_permission
|| ((signals.declarative_net_request || signals.web_request_blocking)
&& signals.broad_host_permissions)
{
return Some("likely");
}
}
if (signals.declarative_net_request || signals.web_request_blocking)
&& signals.broad_host_permissions
&& keyword
{
return Some("likely");
if signals.proxy_permission {
return Some("capability");
}
None
}
pub fn signal_labels(signals: &ManifestSignals, keyword: bool) -> Vec<String> {
pub fn signal_labels(
extension_id: Option<&str>,
signals: &ManifestSignals,
keyword: bool,
) -> Vec<String> {
let mut out = Vec::new();
if extension_id.is_some_and(is_known_vpn_extension) {
out.push("knownVpnExtension".to_string());
}
if signals.proxy_permission {
out.push("permissions:proxy".to_string());
}
@@ -201,11 +296,23 @@ mod tests {
signals_from_manifest(&manifest)
}
fn classify_named(
manifest: serde_json::Value,
name: &str,
description: Option<&str>,
) -> Option<&'static str> {
let s = signals_of(manifest);
classify(None, &s, vpn_keyword_hit(name, description))
}
#[test]
fn classify_confirms_on_proxy_permission() {
fn classify_confirms_a_self_described_vpn_holding_the_proxy_permission() {
let s = signals_of(json!({ "permissions": ["proxy", "storage"] }));
assert!(s.proxy_permission);
assert_eq!(classify(&s, false), Some("confirmed"));
assert_eq!(
classify(None, &s, vpn_keyword_hit("Turbo VPN", None)),
Some("confirmed")
);
}
#[test]
@@ -216,13 +323,57 @@ mod tests {
"manifest_version": 2,
"permissions": ["proxy", "<all_urls>", "webRequest"]
}));
assert_eq!(classify(&s, false), Some("confirmed"));
assert_eq!(
classify(None, &s, vpn_keyword_hit("Hoxx VPN Proxy", None)),
Some("confirmed")
);
}
#[test]
fn classify_likely_on_optional_proxy() {
let s = signals_of(json!({ "optional_permissions": ["proxy"] }));
assert_eq!(classify(&s, false), Some("likely"));
fn a_download_manager_is_reported_as_a_capability_never_as_a_vpn() {
// The bug this whole split exists for. IDM Integration Module declares
// `proxy` so the desktop binary can replicate the browser's route for a
// handed-off download, and says nothing about VPNs anywhere. Verified
// against the real published manifest.
let verdict = classify_named(
json!({
"permissions": [
"scripting", "tabs", "cookies", "contextMenus", "webNavigation",
"webRequest", "declarativeNetRequest", "downloads", "downloads.shelf",
"downloads.ui", "management", "storage", "proxy", "nativeMessaging"
]
}),
"IDM Integration Module",
Some("Download files with Internet Download Manager"),
);
assert_eq!(verdict, Some("capability"));
}
#[test]
fn a_known_vpn_is_confirmed_from_its_id_alone() {
// Hotspot Shield's name contains no keyword at all, so without the id list
// the biggest VPN in the store would be indistinguishable from a download
// manager.
let s = signals_of(json!({ "permissions": ["proxy"] }));
let id = "nlbejmccbhkncgokjcmghpfloaajcffj";
assert_eq!(
classify(Some(id), &s, vpn_keyword_hit("Hotspot Shield", None)),
Some("confirmed")
);
assert!(signal_labels(Some(id), &s, false).contains(&"knownVpnExtension".to_string()));
}
#[test]
fn the_known_vpn_id_list_is_sorted_and_well_formed() {
// Membership is a binary search, so an unsorted entry is silently missed.
assert!(KNOWN_VPN_EXTENSION_IDS.windows(2).all(|w| w[0] < w[1]));
for id in KNOWN_VPN_EXTENSION_IDS {
assert_eq!(id.len(), 32, "{id} is not a Chrome extension id");
assert!(
id.bytes().all(|b| (b'a'..=b'p').contains(&b)),
"{id} is not a Chrome extension id"
);
}
}
#[test]
@@ -234,7 +385,10 @@ mod tests {
"host_permissions": ["<all_urls>"]
}));
assert!(s.declarative_net_request && s.broad_host_permissions);
assert_eq!(classify(&s, keyword_hit("uBlock Origin", None)), None);
assert_eq!(
classify(None, &s, vpn_keyword_hit("uBlock Origin", None)),
None
);
}
#[test]
@@ -244,16 +398,34 @@ mod tests {
"host_permissions": ["<all_urls>"]
}));
assert_eq!(
classify(&s, keyword_hit("Free VPN Proxy", None)),
classify(None, &s, vpn_keyword_hit("Free VPN Proxy", None)),
Some("likely")
);
}
#[test]
fn classify_likely_on_optional_proxy_plus_keyword() {
// Optional and ungranted is not a capability, so it only matters when the
// extension also says what it is.
let s = signals_of(json!({ "optional_permissions": ["proxy"] }));
assert_eq!(
classify(None, &s, vpn_keyword_hit("Some VPN", None)),
Some("likely")
);
assert_eq!(
classify(None, &s, vpn_keyword_hit("Request Interceptor", None)),
None
);
}
#[test]
fn classify_ignores_keyword_only() {
// A name alone proves nothing; without a capability signal this is noise.
let s = signals_of(json!({ "permissions": ["storage"] }));
assert_eq!(classify(&s, keyword_hit("VPN Deals Finder", None)), None);
assert_eq!(
classify(None, &s, vpn_keyword_hit("VPN Deals Finder", None)),
None
);
}
#[test]
@@ -262,7 +434,7 @@ mod tests {
"permissions": ["declarativeNetRequest"],
"host_permissions": ["https://example.com/*"]
}));
assert_eq!(classify(&s, keyword_hit("Some VPN", None)), None);
assert_eq!(classify(None, &s, vpn_keyword_hit("Some VPN", None)), None);
}
#[test]
@@ -283,19 +455,41 @@ mod tests {
"permissions": ["webRequest", "webRequestBlocking", "<all_urls>"]
}));
assert!(s.broad_host_permissions);
assert_eq!(classify(&s, keyword_hit("Turbo VPN", None)), Some("likely"));
assert_eq!(
classify(None, &s, vpn_keyword_hit("Turbo VPN", None)),
Some("likely")
);
}
#[test]
fn keyword_matching_is_substring_but_token_bound_for_short_terms() {
assert!(keyword_hit("TouchVPN", None));
assert!(keyword_hit("Unblock Sites", None));
assert!(keyword_hit("Cloudflare WARP", None));
// "warp" only matches as a whole token, so this must not hit.
assert!(!keyword_hit("Time Warped Clock", None));
assert!(keyword_hit(
fn keyword_matching_reads_the_name_broadly_and_the_description_narrowly() {
assert!(vpn_keyword_hit("TouchVPN", None));
assert!(vpn_keyword_hit("Unblock Sites", None));
assert!(vpn_keyword_hit("Shadowsocks Client", None));
// Whole-token terms must not match inside longer words. "socks" in a name
// is the protocol often enough to keep; "tussocks" and "tunnelling" are
// exactly why it cannot be a substring.
assert!(vpn_keyword_hit("SOCKS5 Configurator", None));
assert!(!vpn_keyword_hit("Tussocks Field Guide", None));
assert!(!vpn_keyword_hit("Tunnelling Contractors CRM", None));
// A description says "VPN" only when it means one...
assert!(vpn_keyword_hit(
"Anything",
Some("a fast tunnel for your browser")
Some("a free VPN for your browser")
));
// ...but these three are ordinary English and must not promote anything.
assert!(!vpn_keyword_hit(
"Requestly",
Some("Modify HTTP requests, no proxy setup required")
));
assert!(!vpn_keyword_hit(
"Stretch Reminder",
Some("Avoid carpal tunnel syndrome while you work")
));
assert!(!vpn_keyword_hit(
"Absolute Right Click",
Some("Unblock right click and text selection on any site")
));
}
@@ -330,6 +524,6 @@ mod tests {
// Arrays of non-strings, wrong types, and missing keys must not panic.
let s = signals_of(json!({ "permissions": [1, 2, {"a": "b"}], "host_permissions": "nope" }));
assert_eq!(s, ManifestSignals::default());
assert_eq!(classify(&s, true), None);
assert_eq!(classify(None, &s, true), None);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.29.0",
"version": "0.29.1",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+45 -4
View File
@@ -627,6 +627,37 @@ async fn cleanup_runtime() {
test_harness::stop_vpn_servers().await;
}
/// Request through the proxy until the tunnel behind it actually carries the
/// traffic, or the deadline passes.
///
/// Returns the last response either way, so a genuine failure still asserts
/// against the real body rather than a timeout message.
async fn wait_for_tunnel(
local_port: u16,
url: &str,
host_header: &str,
timeout: Duration,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let last = match raw_http_request_via_proxy(local_port, url, host_header).await {
Ok(response) => {
if response.contains("WG-TUNNEL-OK") {
return Ok(response);
}
response
}
Err(e) => format!("request error: {e}"),
};
if tokio::time::Instant::now() >= deadline {
return Ok(last);
}
sleep(Duration::from_millis(250)).await;
}
}
async fn wait_for_file(
path: &std::path::Path,
timeout: Duration,
@@ -661,12 +692,22 @@ async fn run_proxy_feature_suite(
let proxy =
start_proxy_with_upstream(binary_path, &vpn_upstream, &[], None, Some(&profile_id)).await?;
sleep(Duration::from_millis(500)).await;
let internal_url = format!("http://{}:8080/", server_tunnel_ip);
let internal_host = format!("{}:8080", server_tunnel_ip);
let http_response =
raw_http_request_via_proxy(proxy.local_port, &internal_url, &internal_host).await?;
// The proxy answers as soon as it is listening, but the route behind it is
// not ready until the WireGuard handshake completes and the in-tunnel server
// accepts. A fixed sleep raced that on a loaded runner and came back
// `502 Bad Gateway`, which is the tunnel not being up yet rather than
// anything under test being wrong. Poll to a deadline instead, the same way
// `wait_for_file` does below.
let http_response = wait_for_tunnel(
proxy.local_port,
&internal_url,
&internal_host,
Duration::from_secs(20),
)
.await?;
assert!(
http_response.contains("WG-TUNNEL-OK"),
"HTTP traffic through donut-proxy+VPN tunnel should succeed, got: {}",
+64 -26
View File
@@ -409,9 +409,19 @@ export default function Home() {
// a bulk run enqueues one per profile, and every waiter must settle or the
// Promise.allSettled below it never resolves and the bulk spinner sticks.
const gateQueueRef = useRef<
Array<{ req: GateRequest; resolve: (decision: GateDecision) => void }>
Array<{
id: number;
req: GateRequest;
/// The bulk run this request belongs to, or undefined for a single
/// launch. Carried per entry so a blanket "apply to the rest" can only
/// ever claim the run its own dialog came from.
runId: number | undefined;
resolve: (decision: GateDecision) => void;
}>
>([]);
const gateRequestSeqRef = useRef(0);
const [gateState, setGateState] = useState<{
id: number;
req: GateRequest;
remaining: number;
} | null>(null);
@@ -993,19 +1003,15 @@ export default function Home() {
[selectedGroupId, t],
);
// Show the queue's head, and how many are waiting behind it.
// The backend gate downgrades to advisory rather than blocking when it
// cannot trust its own measurement (a confirmed VPN extension can reroute
// traffic away from the proxy it just probed), and for unattended launches.
// Without a listener that finding was emitted into the void.
// Unattended launches — REST and MCP automation — are the only ones the
// backend gate lets past a measured mismatch, because there is no dialog for
// them to answer. Without a listener that finding was emitted into the void.
useEffect(() => {
const unlisten = listen<ConsistencyResult>(
"fingerprint-consistency-warning",
(event) => {
const { exit_timezone, fingerprint_timezone } = event.payload;
showErrorToast(t("backendErrors.fingerprintExitMismatch"), {
// The cause differs by path (an unverifiable measurement vs an
// unattended launch), so state the measurement rather than guess.
description:
exit_timezone && fingerprint_timezone
? t("consistencyWarning.timezoneDetail", {
@@ -1024,11 +1030,12 @@ export default function Home() {
};
}, [t]);
// Show the queue's head, and how many are waiting behind it.
const syncGateUi = useCallback(() => {
const queue = gateQueueRef.current;
setGateState(
queue.length > 0
? { req: queue[0].req, remaining: queue.length - 1 }
? { id: queue[0].id, req: queue[0].req, remaining: queue.length - 1 }
: null,
);
}, []);
@@ -1053,7 +1060,13 @@ export default function Home() {
});
}
return new Promise<GateDecision>((resolve) => {
gateQueueRef.current.push({ req, resolve });
gateRequestSeqRef.current += 1;
gateQueueRef.current.push({
id: gateRequestSeqRef.current,
req,
runId,
resolve,
});
syncGateUi();
});
},
@@ -1063,24 +1076,37 @@ export default function Home() {
const settleGate = useCallback(
(decision: GateDecision) => {
const entry = gateQueueRef.current.shift();
entry?.resolve(decision);
if (!entry) {
return;
}
entry.resolve(decision);
if (decision.applyToRemaining) {
const coversBlocking = entry?.req.findings.fingerprint !== null;
blanketGateDecisionRef.current = {
decision,
coversBlocking,
runId: bulkRunIdRef.current,
};
const coversBlocking = entry.req.findings.fingerprint !== null;
// Only a bulk run gets a standing blanket, and it claims the run the
// answered dialog belonged to — never whichever run happens to be in
// flight when the dialog is settled. Outside a run there is nothing to
// scope one to, and a session-wide blanket would silently answer
// unrelated launches later. The queue is still drained either way,
// which is what the checkbox actually promises.
if (entry.runId !== undefined) {
blanketGateDecisionRef.current = {
decision,
coversBlocking,
runId: entry.runId,
};
}
// Drain the queue rather than leaving promises pending forever — but
// only those the blanket actually covers. A hard block still deserves
// its own dialog even after the user blanket-approved a warning.
// its own dialog even after the user blanket-approved a warning, and a
// launch started outside this run was never part of the answer.
const remaining = gateQueueRef.current.splice(0);
const kept = remaining.filter(
(queued) =>
!coversBlocking && queued.req.findings.fingerprint !== null,
);
const kept = [];
for (const queued of remaining) {
if (kept.includes(queued)) {
const covered =
queued.runId === entry.runId &&
(coversBlocking || queued.req.findings.fingerprint === null);
if (!covered) {
kept.push(queued);
continue;
}
queued.resolve({
@@ -1167,6 +1193,12 @@ export default function Home() {
// verdict. No network, no worker started, so a profile whose exit is
// already known blocks before the launch touches anything.
let consentToken: string | null = null;
// Kept for the tier-2 dialog below: the extensions are the same ones,
// and a mismatch measured mid-launch is exactly when knowing that one of
// them can change the proxy matters most. Minus anything the user just
// acknowledged, so a box they ticked seconds ago is not shown again.
let localChecks: PreLaunchChecks | null = null;
let ackedExtensionKeys: string[] = [];
try {
// One-shot migration of the old per-profile "don't warn again" flag,
// so a user who already dismissed this profile isn't hard-blocked by
@@ -1188,6 +1220,7 @@ export default function Home() {
"get_profile_pre_launch_checks",
{ profileId: profile.id },
);
localChecks = checks;
const blocked =
checks.consistency.checked && !checks.consistency.consistent;
if (blocked || checks.vpn_extensions.length > 0) {
@@ -1208,6 +1241,7 @@ export default function Home() {
if (!decision.proceed) {
return { status: "cancelled" };
}
ackedExtensionKeys = decision.ackExtensionKeys;
consentToken = checks.consent_token;
}
} catch (err) {
@@ -1234,10 +1268,13 @@ export default function Home() {
{
profile,
findings: {
vpnExtensions: [],
scanState: "scanned",
vpnExtensions: (localChecks?.vpn_extensions ?? []).filter(
(ext) => !ackedExtensionKeys.includes(ext.key),
),
scanState: localChecks?.scan_state ?? "scanned",
fingerprint: consistencyFromErrorParams(parsed.params),
measurementUnreliable: false,
measurementUnreliable:
localChecks?.exit_measurement_unreliable ?? false,
probePending: false,
},
},
@@ -2186,6 +2223,7 @@ export default function Home() {
isOpen={gateState !== null}
profileName={gateState?.req.profile.name ?? ""}
profileId={gateState?.req.profile.id ?? ""}
requestId={gateState?.id ?? 0}
findings={gateState?.req.findings ?? null}
remainingCount={gateState?.remaining ?? 0}
onResult={settleGate}
+192 -55
View File
@@ -1,7 +1,7 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuTriangleAlert } from "react-icons/lu";
import { Checkbox } from "@/components/ui/checkbox";
@@ -15,16 +15,21 @@ import {
import { Label } from "@/components/ui/label";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { ConsistencyResult, DetectedVpnExtension } from "@/types";
import type {
ConsistencyResult,
DetectedVpnExtension,
ExtensionScanState,
} from "@/types";
import { RippleButton } from "./ui/ripple";
export interface GateFindings {
/// Extensions that can reroute traffic. A warning: the user may proceed.
/// Extensions that could reroute traffic. A warning: the user may proceed.
vpnExtensions: DetectedVpnExtension[];
scanState: string;
scanState: ExtensionScanState;
/// A measured exit/fingerprint mismatch. A block: the browser has not started.
fingerprint: ConsistencyResult | null;
/// A confirmed proxy-permission extension makes any exit measurement suspect.
/// An extension holds the proxy permission, so the exit measurement may not
/// describe the route the browser takes. A caveat on the block, not a waiver.
measurementUnreliable: boolean;
/// The exit has not been measured yet; the launch itself will still check.
probePending: boolean;
@@ -41,6 +46,9 @@ interface PreLaunchGateDialogProps {
isOpen: boolean;
profileName: string;
profileId: string;
/// Identifies this specific request, so state resets even when one gate
/// replaces another without the dialog ever closing.
requestId: number;
findings: GateFindings | null;
/// How many further profiles are queued behind this one; >0 offers to apply
/// the same decision to all of them.
@@ -50,51 +58,155 @@ interface PreLaunchGateDialogProps {
onResult: (decision: GateDecision) => void;
}
/// Everything the user can change while one gate is on screen, stamped with
/// the gate it belongs to.
interface GateAnswerState {
requestId: number;
ackFingerprint: boolean;
ackExtensions: boolean;
applyToRemaining: boolean;
isMatching: boolean;
decided: boolean;
}
/// How long after a decision the footer stops accepting another one. Long
/// enough that a double-click cannot answer the gate promoted by its first
/// half, short enough that nobody deliberately answering two queued gates in a
/// row notices it.
const DECISION_COOLDOWN_MS = 500;
function answersFor(requestId: number): GateAnswerState {
return {
requestId,
ackFingerprint: false,
ackExtensions: false,
applyToRemaining: false,
isMatching: false,
decided: false,
};
}
function ExtensionEntry({ extension }: { extension: DetectedVpnExtension }) {
const { t } = useTranslation();
const capability = t(
extension.confidence === "confirmed"
? "prelaunchGate.vpnExtensionConfirmed"
: extension.confidence === "likely"
? "prelaunchGate.vpnExtensionLikely"
: "prelaunchGate.vpnExtensionCapability",
);
const source = t(
extension.source === "donut"
? "prelaunchGate.sourceDonut"
: "prelaunchGate.sourceBrowser",
);
return (
<li className="text-xs">
<span className="font-medium">{extension.name}</span>
<span className="text-muted-foreground">
{/* A version-less manifest is legal, and interpolating an empty string
into the one template left a doubled space before the dash. */}
{extension.version
? t("prelaunchGate.vpnExtensionEntry", {
version: extension.version,
capability,
source,
})
: t("prelaunchGate.vpnExtensionEntryNoVersion", {
capability,
source,
})}
</span>
</li>
);
}
export function PreLaunchGateDialog({
isOpen,
profileName,
profileId,
requestId,
findings,
remainingCount,
onResult,
}: PreLaunchGateDialogProps) {
const { t } = useTranslation();
const [ackFingerprint, setAckFingerprint] = useState(false);
const [ackExtensions, setAckExtensions] = useState(false);
const [applyToRemaining, setApplyToRemaining] = useState(false);
const [isMatching, setIsMatching] = useState(false);
// The dialog node is reused as the queue advances, so without this a double
// click would decide for the next profile too.
const [decided, setDecided] = useState(false);
// All mutable state is stamped with the request it belongs to, and anything
// stamped with an older request is ignored rather than reset. The dialog
// never unmounts and a queued gate promotes the next profile without ever
// closing it, so state carried across that boundary would tick a checkbox
// for a profile the user never saw — and `decided` carried across it left
// every button disabled on a dialog that also refused Escape, which is the
// freeze this shape exists to make unrepresentable.
//
// Deliberately not an effect keyed on `requestId`: a reset effect whose body
// reads none of its dependencies is exactly what a lint autofix reduces to
// `[]`, and that is how the freeze shipped.
const [state, setState] = useState<GateAnswerState>(() => answersFor(0));
const answers = state.requestId === requestId ? state : answersFor(requestId);
// Keyed on profileId, not just isOpen: a queued gate promotes the next
// profile without ever closing the dialog, so an isOpen-only reset would
// carry the previous profile's ticked boxes — and persist an acknowledgement
// against a profile the user never saw.
// The gate on screen right now, readable from an async callback whose
// closure was captured while an earlier gate was showing.
const liveRequestRef = useRef(requestId);
useEffect(() => {
setAckFingerprint(false);
setAckExtensions(false);
setIsMatching(false);
setDecided(false);
}, []);
liveRequestRef.current = requestId;
}, [requestId]);
useEffect(() => {
if (isOpen) {
setApplyToRemaining(false);
const patch = (next: Partial<GateAnswerState>) => {
// A callback that resumes after its gate was answered must not write into
// the slot the next gate is now using — that would silently untick boxes
// the user has since ticked on a different profile.
if (liveRequestRef.current !== requestId) {
return;
}
}, [isOpen]);
setState((prev) => ({
...(prev.requestId === requestId ? prev : answersFor(requestId)),
...next,
requestId,
}));
};
const {
ackFingerprint,
ackExtensions,
applyToRemaining,
isMatching,
decided,
} = answers;
const fingerprint = findings?.fingerprint ?? null;
const extensions = findings?.vpnExtensions ?? [];
// Two different claims, kept visually apart. The first names extensions as
// VPN/proxy tools; the second says only that an extension holds Chromium's
// proxy permission, which a download manager needs to route its own
// transfers and which says nothing about what the extension is.
const vpnExtensions = extensions.filter((e) => e.confidence !== "capability");
const proxyCapableExtensions = extensions.filter(
(e) => e.confidence === "capability",
);
const mismatches = fingerprint?.mismatches ?? [];
const exitIp = fingerprint?.exit_ip ?? null;
const isBlocked = fingerprint !== null;
// Two guards, because answering a gate promotes the next one into the same
// DOM node rather than closing the dialog. The ref settles one gate exactly
// once even if two clicks land in the same React batch; the cooldown stops
// the second half of a double-click from answering a dialog that appeared
// between the two clicks and that nobody has read.
const decidedRef = useRef<number | null>(null);
const lastDecisionAtRef = useRef(Number.NEGATIVE_INFINITY);
const decide = (proceed: boolean) => {
if (decided) {
if (decided || decidedRef.current === requestId) {
return;
}
setDecided(true);
const now = performance.now();
if (now - lastDecisionAtRef.current < DECISION_COOLDOWN_MS) {
return;
}
decidedRef.current = requestId;
lastDecisionAtRef.current = now;
patch({ decided: true });
onResult({
proceed,
ackFingerprint: ackFingerprint && isBlocked,
@@ -107,21 +219,29 @@ export function PreLaunchGateDialog({
if (!exitIp) {
return;
}
setIsMatching(true);
const request = requestId;
patch({ isMatching: true });
try {
await invoke("match_profile_fingerprint_to_exit", {
profileId,
exitIp,
});
showSuccessToast(t("consistencyWarning.matchSuccess"));
patch({ isMatching: false });
// Rewriting the fingerprint takes long enough for the user to dismiss
// this gate meanwhile. The profile change still stands, but the launch
// it belonged to is already settled, and deciding now would answer
// whichever gate took its place.
if (liveRequestRef.current !== request) {
return;
}
// The fingerprint the block was measured against no longer exists, so
// this launch is abandoned rather than forced through with a stale
// consent token; the user relaunches against the corrected profile.
decide(false);
} catch (e) {
showErrorToast(translateBackendError(t, e));
} finally {
setIsMatching(false);
patch({ isMatching: false });
}
};
@@ -141,8 +261,19 @@ export function PreLaunchGateDialog({
})();
return (
<Dialog open={isOpen}>
<DialogContent className="sm:max-w-md" dismissible={false}>
// Dismissible on purpose: cancelling is the safe outcome, so every way out
// of this dialog — Escape, the close X, a click outside — resolves the
// waiting launch as "don't start". A gate that can only be answered by two
// buttons is one disabled button away from trapping the whole app.
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) {
decide(false);
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuTriangleAlert className="size-5 text-warning-text" />
@@ -184,7 +315,7 @@ export function PreLaunchGateDialog({
</div>
)}
{extensions.length > 0 && (
{vpnExtensions.length > 0 && (
<div className="space-y-2 rounded-md border border-warning/50 bg-warning/10 p-3">
<p className="font-medium">
{t("prelaunchGate.vpnExtensionHeading")}
@@ -193,23 +324,8 @@ export function PreLaunchGateDialog({
{t("prelaunchGate.vpnExtensionIntro")}
</p>
<ul className="space-y-1">
{extensions.map((ext) => (
<li key={ext.key} className="text-xs">
<span className="font-medium">{ext.name}</span>
<span className="text-muted-foreground">
{t("prelaunchGate.vpnExtensionEntry", {
version: ext.version ?? "",
capability:
ext.confidence === "confirmed"
? t("prelaunchGate.vpnExtensionConfirmed")
: t("prelaunchGate.vpnExtensionLikely"),
source:
ext.source === "donut"
? t("prelaunchGate.sourceDonut")
: t("prelaunchGate.sourceBrowser"),
})}
</span>
</li>
{vpnExtensions.map((ext) => (
<ExtensionEntry key={ext.key} extension={ext} />
))}
</ul>
<p className="text-xs text-muted-foreground">
@@ -218,6 +334,22 @@ export function PreLaunchGateDialog({
</div>
)}
{proxyCapableExtensions.length > 0 && (
<div className="space-y-2 rounded-md border border-border bg-muted/40 p-3">
<p className="font-medium">
{t("prelaunchGate.proxyCapableHeading")}
</p>
<p className="text-xs text-muted-foreground">
{t("prelaunchGate.proxyCapableIntro")}
</p>
<ul className="space-y-1">
{proxyCapableExtensions.map((ext) => (
<ExtensionEntry key={ext.key} extension={ext} />
))}
</ul>
</div>
)}
{findings?.measurementUnreliable && isBlocked && (
<p className="text-xs text-muted-foreground">
{t("prelaunchGate.measurementUnreliable")}
@@ -240,7 +372,7 @@ export function PreLaunchGateDialog({
<Checkbox
id="gate-ack-fingerprint"
checked={ackFingerprint}
onCheckedChange={(v) => setAckFingerprint(v === true)}
onCheckedChange={(v) => patch({ ackFingerprint: v === true })}
/>
<Label htmlFor="gate-ack-fingerprint" className="text-xs">
{t("prelaunchGate.dontBlockAgain")}
@@ -252,7 +384,7 @@ export function PreLaunchGateDialog({
<Checkbox
id="gate-ack-extensions"
checked={ackExtensions}
onCheckedChange={(v) => setAckExtensions(v === true)}
onCheckedChange={(v) => patch({ ackExtensions: v === true })}
/>
<Label htmlFor="gate-ack-extensions" className="text-xs">
{t("prelaunchGate.dontWarnExtensions")}
@@ -264,7 +396,9 @@ export function PreLaunchGateDialog({
<Checkbox
id="gate-apply-remaining"
checked={applyToRemaining}
onCheckedChange={(v) => setApplyToRemaining(v === true)}
onCheckedChange={(v) =>
patch({ applyToRemaining: v === true })
}
/>
<Label htmlFor="gate-apply-remaining" className="text-xs">
{t("prelaunchGate.applyToRemaining")}
@@ -276,11 +410,14 @@ export function PreLaunchGateDialog({
<DialogFooter className="flex-row justify-between sm:justify-between">
{/* Cancel is the default action: the browser has not started, and
not starting it is the safe outcome. */}
not starting it is the safe outcome. Never disabled by `decided`
`decide` is already idempotent, and the one control that ends
the dialog safely must not be something a stale flag can switch
off. */}
<RippleButton
variant="outline"
onClick={() => decide(false)}
disabled={isMatching || decided}
disabled={isMatching}
autoFocus
>
{t("common.buttons.cancel")}
+60 -16
View File
@@ -232,16 +232,30 @@ export function SettingsDialog({
[t],
);
const applyCustomTheme = useCallback((vars: Record<string, string>) => {
withThemeTransition(() => {
applyThemeColors(vars);
});
}, []);
// `animate: false` on the restore paths. Opening Settings re-applies the
// theme already on screen, so a whole-document cross-fade to an identical
// palette animates nothing. Worse, the mount effect below did it twice in a
// row, and the second transition aborts the first mid-snapshot.
const applyCustomTheme = useCallback(
(vars: Record<string, string>, options?: { animate?: boolean }) => {
const apply = () => {
applyThemeColors(vars);
};
if (options?.animate === false) {
apply();
return;
}
withThemeTransition(apply);
},
[],
);
const clearCustomTheme = useCallback(() => {
withThemeTransition(() => {
const clearCustomTheme = useCallback((options?: { animate?: boolean }) => {
if (options?.animate === false) {
clearThemeColors();
});
return;
}
withThemeTransition(clearThemeColors);
}, []);
const loadSettings = useCallback(async () => {
@@ -363,12 +377,24 @@ export function SettingsDialog({
isMicrophoneAccessGranted,
]);
// The Linux implementation shells out to `which` plus two `xdg-mime query`
// calls, and `xdg-mime` is a shell script that forks further. Without this
// guard a slow desktop lets the poll below stack one unfinished call on top
// of another every few seconds, and each one occupies a worker of the same
// runtime every other Tauri command shares.
const defaultBrowserCheckInFlight = useRef(false);
const checkDefaultBrowserStatus = useCallback(async () => {
if (defaultBrowserCheckInFlight.current) {
return;
}
defaultBrowserCheckInFlight.current = true;
try {
const isDefault = await invoke<boolean>("is_default_browser");
setIsDefaultBrowser(isDefault);
} catch (error) {
console.error("Failed to check default browser status:", error);
} finally {
defaultBrowserCheckInFlight.current = false;
}
}, []);
@@ -565,11 +591,13 @@ export function SettingsDialog({
const handleClose = useCallback(() => {
// Restore original theme when closing without saving
// Only a revert the user can see is worth animating.
const changed = originalSettings.theme !== settings.theme;
if (originalSettings.theme === "custom" && originalSettings.custom_theme) {
applyCustomTheme(originalSettings.custom_theme);
applyCustomTheme(originalSettings.custom_theme, { animate: changed });
} else {
clearCustomTheme();
setTheme(originalSettings.theme);
clearCustomTheme({ animate: false });
setTheme(originalSettings.theme, { animate: changed });
}
// Reset custom theme state to original
@@ -589,16 +617,29 @@ export function SettingsDialog({
clearCustomTheme,
onClose,
setTheme,
settings.theme,
]);
// Only clear custom theme when switching away from custom, don't apply live
// changes. Gated on the async settings load: before it resolves the state
// still holds the "system" default, and clearing then wipes the user's
// custom theme vars on every Settings visit (the theme-reverts-to-dark bug).
//
// This effect is both the restore-on-open and the live switch when the user
// picks a theme, so it animates only a real change: the first run after the
// settings load is re-applying the palette already on screen. Clearing the
// inline custom vars is never the animated half — switching to a stylesheet
// palette makes them invisible either way, and running two transitions
// back to back just aborts the first one mid-snapshot.
const appliedThemeRef = useRef<string | null>(null);
useEffect(() => {
if (hasLoadedSettings && settings.theme !== "custom") {
clearCustomTheme();
setTheme(settings.theme);
const previous = appliedThemeRef.current;
appliedThemeRef.current = settings.theme;
clearCustomTheme({ animate: false });
setTheme(settings.theme, {
animate: previous !== null && previous !== settings.theme,
});
}
}, [hasLoadedSettings, settings.theme, clearCustomTheme, setTheme]);
@@ -616,7 +657,7 @@ export function SettingsDialog({
// stylesheet palette — strip any leftover inline custom vars so a
// just-saved switch away from custom isn't reverted on unmount.
clearThemeColors();
setTheme(s.theme);
setTheme(s.theme, { animate: false });
}
};
}, [setTheme]);
@@ -634,12 +675,15 @@ export function SettingsDialog({
loadPermissions();
}
// Set up interval to check default browser status
// Re-check periodically so the badge follows a change the user made in
// their desktop settings. Ten seconds rather than two: on Linux each
// check is three subprocesses, and nobody flips their default browser
// often enough to notice the difference.
const intervalId = setInterval(() => {
checkDefaultBrowserStatus().catch((err: unknown) => {
console.error(err);
});
}, 2000);
}, 10000);
// Cleanup interval on component unmount or dialog close
return () => {
+22 -8
View File
@@ -22,7 +22,9 @@ interface AppSettings {
interface ThemeContextValue {
theme: string;
setTheme: (theme: string) => void;
/// `animate: false` applies the theme without a view transition, for the
/// restore paths where nothing visually changes.
setTheme: (theme: string, options?: { animate?: boolean }) => void;
}
const ThemeContext = createContext<ThemeContextValue>({
@@ -56,14 +58,26 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
const [isLoading, setIsLoading] = useState(true);
const [theme, setThemeState] = useState("system");
const setTheme = useCallback((newTheme: string) => {
setThemeState(newTheme);
withThemeTransition(() => {
if (newTheme !== "custom") {
applyClassToHtml(newTheme);
// `animate: false` is for restoring the theme the app is already showing —
// opening or leaving Settings re-applies the current theme, and cross-fading
// the whole document to the palette already on screen animates nothing while
// still paying for a full-document snapshot.
const setTheme = useCallback(
(newTheme: string, options?: { animate?: boolean }) => {
setThemeState(newTheme);
const apply = () => {
if (newTheme !== "custom") {
applyClassToHtml(newTheme);
}
};
if (options?.animate === false) {
apply();
return;
}
});
}, []);
withThemeTransition(apply);
},
[],
);
// Load initial theme from Tauri settings
useEffect(() => {
+11 -8
View File
@@ -2178,7 +2178,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot warms your profiles overnight on a remote machine, so they keep their cookies and their history without your computer being on. It needs a Pro or Team plan."
"hint": "Cookie Bot warms your profiles overnight on a remote machine, so they keep their cookies and their history without your computer being on. It needs a paid plan."
},
"empty": {
"title": "No profiles are enrolled",
@@ -2428,7 +2428,7 @@
},
"actionBar": {
"enrol": "Enrol in Cookie Bot",
"proRequired": "Cookie Bot requires a Pro or Team plan",
"proRequired": "Cookie Bot requires a paid plan",
"noneEligible": "None of the selected profiles can be warmed remotely"
},
"actions": {
@@ -2488,15 +2488,18 @@
"titleBlocked": "Launch blocked",
"titleWarning": "Before you launch",
"intro": "Review these issues with \"{{name}}\" before starting the browser.",
"fingerprintHeading": "Proxy exit doesn't match the fingerprint",
"vpnExtensionHeading": "VPN extension detected",
"fingerprintHeading": "The measured exit doesn't match the fingerprint",
"vpnExtensionHeading": "VPN or proxy extension detected",
"vpnExtensionIntro": "Extensions in this profile that can reroute the browser's traffic:",
"vpnExtensionConfirmed": "Can change the proxy",
"vpnExtensionLikely": "May change the proxy",
"vpnExtensionConfirmed": "Known VPN or proxy tool",
"vpnExtensionLikely": "Looks like a VPN or proxy tool",
"vpnExtensionCapability": "Holds the proxy permission",
"vpnExtensionExplainer": "If one of these routes your traffic elsewhere, the browser's real location will no longer match the timezone, language and geolocation this profile was created with, and Donut cannot detect that from the outside.",
"proxyCapableHeading": "Extensions that can change the proxy",
"proxyCapableIntro": "These don't look like VPNs, but they hold Chromium's proxy permission, which download managers and debugging tools need too. Donut cannot tell whether any of them is using it:",
"sourceDonut": "Managed by Donut",
"sourceBrowser": "Installed in the profile",
"measurementUnreliable": "Because a VPN extension can override the proxy, the exit check may not describe the route the browser actually takes.",
"measurementUnreliable": "An extension in this profile holds the proxy permission, so the exit check may not describe the route the browser actually takes.",
"scanIncompleteEncrypted": "This profile is encrypted, so only Donut-managed extensions could be checked.",
"scanIncompleteEphemeral": "This profile has no data yet, so only Donut-managed extensions could be checked.",
"scanIncompletePartial": "The extension scan was cut short, so some extensions may not be listed.",
@@ -2506,8 +2509,8 @@
"dontWarnExtensions": "Don't warn again about these extensions",
"applyToRemaining": "Apply this choice to the remaining profiles",
"cancelledSummary": "{{cancelled}} of {{total}} launches cancelled",
"cancelled": "Launch cancelled",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"vpnExtensionEntryNoVersion": " — {{capability}}, {{source}}",
"scanIncompleteMissing": "This profile has not been launched yet, so only Donut-managed extensions could be checked."
}
}
+11 -8
View File
@@ -2185,7 +2185,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot calienta tus perfiles por la noche en una máquina remota, así conservan sus cookies y su historial sin que tu ordenador esté encendido. Requiere un plan Pro o Team."
"hint": "Cookie Bot calienta tus perfiles por la noche en una máquina remota, así conservan sus cookies y su historial sin que tu ordenador esté encendido. Requiere un plan de pago."
},
"empty": {
"title": "No hay perfiles inscritos",
@@ -2456,7 +2456,7 @@
},
"actionBar": {
"enrol": "Inscribir en Cookie Bot",
"proRequired": "Cookie Bot requiere un plan Pro o Team",
"proRequired": "Cookie Bot requiere un plan de pago",
"noneEligible": "Ninguno de los perfiles seleccionados se puede calentar en remoto"
},
"actions": {
@@ -2517,15 +2517,18 @@
"titleBlocked": "Inicio bloqueado",
"titleWarning": "Antes de iniciar",
"intro": "Revisa estos problemas de \"{{name}}\" antes de iniciar el navegador.",
"fingerprintHeading": "La salida del proxy no coincide con la huella digital",
"vpnExtensionHeading": "Extensión VPN detectada",
"fingerprintHeading": "La salida medida no coincide con la huella digital",
"vpnExtensionHeading": "Extensión de VPN o proxy detectada",
"vpnExtensionIntro": "Extensiones de este perfil que pueden redirigir el tráfico del navegador:",
"vpnExtensionConfirmed": "Puede cambiar el proxy",
"vpnExtensionLikely": "Podría cambiar el proxy",
"vpnExtensionConfirmed": "Herramienta de VPN o proxy conocida",
"vpnExtensionLikely": "Parece una herramienta de VPN o proxy",
"vpnExtensionCapability": "Tiene el permiso de proxy",
"vpnExtensionExplainer": "Si alguna de ellas redirige tu tráfico a otro lugar, la ubicación real del navegador dejará de coincidir con la zona horaria, el idioma y la geolocalización con los que se creó este perfil, y Donut no puede detectarlo desde fuera.",
"proxyCapableHeading": "Extensiones que pueden cambiar el proxy",
"proxyCapableIntro": "No parecen VPN, pero tienen el permiso de proxy de Chromium, que también necesitan los gestores de descargas y las herramientas de depuración. Donut no puede saber si alguna lo está usando:",
"sourceDonut": "Gestionada por Donut",
"sourceBrowser": "Instalada en el perfil",
"measurementUnreliable": "Como una extensión VPN puede anular el proxy, la comprobación de salida podría no reflejar la ruta que el navegador usa realmente.",
"measurementUnreliable": "Una extensión de este perfil tiene el permiso de proxy, así que la comprobación de salida podría no reflejar la ruta que el navegador usa realmente.",
"scanIncompleteEncrypted": "Este perfil está cifrado, así que solo se pudieron comprobar las extensiones gestionadas por Donut.",
"scanIncompleteEphemeral": "Este perfil aún no tiene datos, así que solo se pudieron comprobar las extensiones gestionadas por Donut.",
"scanIncompletePartial": "El análisis de extensiones se interrumpió, así que puede que falten algunas.",
@@ -2535,8 +2538,8 @@
"dontWarnExtensions": "No volver a avisar sobre estas extensiones",
"applyToRemaining": "Aplicar esta decisión a los perfiles restantes",
"cancelledSummary": "{{cancelled}} de {{total}} inicios cancelados",
"cancelled": "Inicio cancelado",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"vpnExtensionEntryNoVersion": " — {{capability}}, {{source}}",
"scanIncompleteMissing": "Este perfil aún no se ha iniciado, así que solo se pudieron comprobar las extensiones gestionadas por Donut."
}
}
+11 -8
View File
@@ -2185,7 +2185,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot chauffe vos profils la nuit sur une machine distante : ils conservent leurs cookies et leur historique sans que votre ordinateur soit allumé. Nécessite un forfait Pro ou Team."
"hint": "Cookie Bot chauffe vos profils la nuit sur une machine distante : ils conservent leurs cookies et leur historique sans que votre ordinateur soit allumé. Nécessite un forfait payant."
},
"empty": {
"title": "Aucun profil inscrit",
@@ -2456,7 +2456,7 @@
},
"actionBar": {
"enrol": "Inscrire à Cookie Bot",
"proRequired": "Cookie Bot nécessite un forfait Pro ou Team",
"proRequired": "Cookie Bot nécessite un forfait payant",
"noneEligible": "Aucun des profils sélectionnés ne peut être chauffé à distance"
},
"actions": {
@@ -2517,15 +2517,18 @@
"titleBlocked": "Lancement bloqué",
"titleWarning": "Avant de lancer",
"intro": "Examinez ces problèmes concernant « {{name}} » avant de démarrer le navigateur.",
"fingerprintHeading": "La sortie du proxy ne correspond pas à l'empreinte",
"vpnExtensionHeading": "Extension VPN détectée",
"fingerprintHeading": "La sortie mesurée ne correspond pas à l'empreinte",
"vpnExtensionHeading": "Extension VPN ou proxy détectée",
"vpnExtensionIntro": "Extensions de ce profil pouvant rerouter le trafic du navigateur :",
"vpnExtensionConfirmed": "Peut changer le proxy",
"vpnExtensionLikely": "Pourrait changer le proxy",
"vpnExtensionConfirmed": "Outil VPN ou proxy connu",
"vpnExtensionLikely": "Semble être un outil VPN ou proxy",
"vpnExtensionCapability": "Détient l'autorisation proxy",
"vpnExtensionExplainer": "Si l'une d'elles redirige votre trafic ailleurs, la position réelle du navigateur ne correspondra plus au fuseau horaire, à la langue et à la géolocalisation avec lesquels ce profil a été créé, et Donut ne peut pas le détecter de l'extérieur.",
"proxyCapableHeading": "Extensions pouvant changer le proxy",
"proxyCapableIntro": "Elles ne ressemblent pas à des VPN, mais elles détiennent l'autorisation proxy de Chromium, dont les gestionnaires de téléchargement et les outils de débogage ont aussi besoin. Donut ne peut pas savoir si l'une d'elles s'en sert :",
"sourceDonut": "Gérée par Donut",
"sourceBrowser": "Installée dans le profil",
"measurementUnreliable": "Comme une extension VPN peut remplacer le proxy, la vérification de la sortie peut ne pas refléter la route réellement empruntée par le navigateur.",
"measurementUnreliable": "Une extension de ce profil détient l'autorisation proxy, la vérification de la sortie peut donc ne pas refléter la route réellement empruntée par le navigateur.",
"scanIncompleteEncrypted": "Ce profil est chiffré : seules les extensions gérées par Donut ont pu être vérifiées.",
"scanIncompleteEphemeral": "Ce profil n'a pas encore de données : seules les extensions gérées par Donut ont pu être vérifiées.",
"scanIncompletePartial": "L'analyse des extensions a été interrompue, certaines peuvent manquer.",
@@ -2535,8 +2538,8 @@
"dontWarnExtensions": "Ne plus m'avertir à propos de ces extensions",
"applyToRemaining": "Appliquer ce choix aux profils restants",
"cancelledSummary": "{{cancelled}} lancements sur {{total}} annulés",
"cancelled": "Lancement annulé",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"vpnExtensionEntryNoVersion": " — {{capability}}, {{source}}",
"scanIncompleteMissing": "Ce profil n'a jamais été lancé : seules les extensions gérées par Donut ont pu être vérifiées."
}
}
+11 -8
View File
@@ -2178,7 +2178,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot はリモートマシンで夜間にプロファイルをウォームアップするため、お使いのコンピューターを起動していなくても Cookie と履歴が維持されます。Pro または Team プランが必要です。"
"hint": "Cookie Bot はリモートマシンで夜間にプロファイルをウォームアップするため、お使いのコンピューターを起動していなくても Cookie と履歴が維持されます。有料プランが必要です。"
},
"empty": {
"title": "登録されたプロファイルはありません",
@@ -2428,7 +2428,7 @@
},
"actionBar": {
"enrol": "Cookie Bot に登録",
"proRequired": "Cookie Bot には Pro または Team プランが必要です",
"proRequired": "Cookie Bot には有料プランが必要です",
"noneEligible": "選択したプロファイルはいずれもリモートでウォームアップできません"
},
"actions": {
@@ -2488,15 +2488,18 @@
"titleBlocked": "起動をブロックしました",
"titleWarning": "起動する前に",
"intro": "ブラウザーを起動する前に、「{{name}}」に関する次の問題を確認してください。",
"fingerprintHeading": "プロキシの出口がフィンガープリントと一致しません",
"vpnExtensionHeading": "VPN拡張機能を検出しました",
"fingerprintHeading": "測定した出口がフィンガープリントと一致しません",
"vpnExtensionHeading": "VPN・プロキシ拡張機能を検出しました",
"vpnExtensionIntro": "このプロファイル内で、ブラウザーの通信を経路変更できる拡張機能:",
"vpnExtensionConfirmed": "プロキシを変更できます",
"vpnExtensionLikely": "プロキシを変更する可能性があります",
"vpnExtensionConfirmed": "既知のVPN・プロキシツール",
"vpnExtensionLikely": "VPN・プロキシツールと思われます",
"vpnExtensionCapability": "プロキシ権限を持っています",
"vpnExtensionExplainer": "いずれかが通信を別の経路に変えると、ブラウザーの実際の所在地は、このプロファイルの作成時に設定されたタイムゾーン・言語・位置情報と一致しなくなります。Donutは外部からそれを検出できません。",
"proxyCapableHeading": "プロキシを変更できる拡張機能",
"proxyCapableIntro": "VPNには見えませんが、Chromiumのプロキシ権限を持っています。ダウンロードマネージャーやデバッグツールにも必要な権限で、実際に使っているかどうかをDonutは判別できません:",
"sourceDonut": "Donutが管理",
"sourceBrowser": "プロファイルにインストール済み",
"measurementUnreliable": "VPN拡張機能プロキシを上書きできるため、出口の確認結果がブラウザーの実際の経路を表していない可能性があります。",
"measurementUnreliable": "このプロファイルの拡張機能プロキシ権限を持っているため、出口の確認結果がブラウザーの実際の経路を表していない可能性があります。",
"scanIncompleteEncrypted": "このプロファイルは暗号化されているため、Donutが管理する拡張機能のみ確認できました。",
"scanIncompleteEphemeral": "このプロファイルにはまだデータがないため、Donutが管理する拡張機能のみ確認できました。",
"scanIncompletePartial": "拡張機能のスキャンが途中で終了したため、一部が表示されていない可能性があります。",
@@ -2506,8 +2509,8 @@
"dontWarnExtensions": "これらの拡張機能について今後警告しない",
"applyToRemaining": "この選択を残りのプロファイルにも適用",
"cancelledSummary": "{{total}}件中{{cancelled}}件の起動をキャンセルしました",
"cancelled": "起動をキャンセルしました",
"vpnExtensionEntry": " {{version}}{{capability}}、{{source}}",
"vpnExtensionEntryNoVersion": "{{capability}}、{{source}}",
"scanIncompleteMissing": "このプロファイルはまだ起動されていないため、Donutが管理する拡張機能のみ確認できました。"
}
}
+11 -8
View File
@@ -2178,7 +2178,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot은 원격 머신에서 밤새 프로필을 예열해, 내 컴퓨터를 켜 두지 않아도 쿠키와 방문 기록이 유지됩니다. Pro 또는 Team 요금제가 필요합니다."
"hint": "Cookie Bot은 원격 머신에서 밤새 프로필을 예열해, 내 컴퓨터를 켜 두지 않아도 쿠키와 방문 기록이 유지됩니다. 유료 요금제가 필요합니다."
},
"empty": {
"title": "등록된 프로필이 없습니다",
@@ -2428,7 +2428,7 @@
},
"actionBar": {
"enrol": "Cookie Bot에 등록",
"proRequired": "Cookie Bot에는 Pro 또는 Team 요금제가 필요합니다",
"proRequired": "Cookie Bot에는 유료 요금제가 필요합니다",
"noneEligible": "선택한 프로필 중 원격으로 예열할 수 있는 것이 없습니다"
},
"actions": {
@@ -2488,15 +2488,18 @@
"titleBlocked": "실행이 차단됨",
"titleWarning": "실행하기 전에",
"intro": "브라우저를 시작하기 전에 \"{{name}}\"의 다음 문제를 확인하세요.",
"fingerprintHeading": "프록시 출구가 핑거프린트와 일치하지 않음",
"vpnExtensionHeading": "VPN 확장 프로그램 감지됨",
"fingerprintHeading": "측정된 출구가 핑거프린트와 일치하지 않음",
"vpnExtensionHeading": "VPN 또는 프록시 확장 프로그램 감지됨",
"vpnExtensionIntro": "이 프로필에서 브라우저 트래픽의 경로를 바꿀 수 있는 확장 프로그램:",
"vpnExtensionConfirmed": "프록시를 변경할 수 있음",
"vpnExtensionLikely": "프록시를 변경할 수 있음(추정)",
"vpnExtensionConfirmed": "알려진 VPN 또는 프록시 도구",
"vpnExtensionLikely": "VPN 또는 프록시 도구로 보임",
"vpnExtensionCapability": "프록시 권한을 보유함",
"vpnExtensionExplainer": "이 중 하나가 트래픽을 다른 곳으로 보내면 브라우저의 실제 위치가 이 프로필을 만들 때 사용한 시간대, 언어, 지리 정보와 더 이상 일치하지 않으며, Donut은 외부에서 이를 감지할 수 없습니다.",
"proxyCapableHeading": "프록시를 변경할 수 있는 확장 프로그램",
"proxyCapableIntro": "VPN으로 보이지는 않지만 Chromium의 프록시 권한을 가지고 있습니다. 다운로드 관리자나 디버깅 도구에도 필요한 권한이며, 실제로 사용하는지는 Donut이 알 수 없습니다:",
"sourceDonut": "Donut이 관리",
"sourceBrowser": "프로필에 설치됨",
"measurementUnreliable": "VPN 확장 프로그램이 프록시를 덮어쓸 수 있으므로, 출구 확인 결과가 브라우저의 실제 경로와 다를 수 있습니다.",
"measurementUnreliable": "이 프로필의 확장 프로그램이 프록시 권한을 가지고 있어, 출구 확인 결과가 브라우저의 실제 경로와 다를 수 있습니다.",
"scanIncompleteEncrypted": "이 프로필은 암호화되어 있어 Donut이 관리하는 확장 프로그램만 확인할 수 있었습니다.",
"scanIncompleteEphemeral": "이 프로필에는 아직 데이터가 없어 Donut이 관리하는 확장 프로그램만 확인할 수 있었습니다.",
"scanIncompletePartial": "확장 프로그램 검사가 중단되어 일부가 표시되지 않을 수 있습니다.",
@@ -2506,8 +2509,8 @@
"dontWarnExtensions": "이 확장 프로그램에 대해 다시 경고하지 않기",
"applyToRemaining": "이 선택을 나머지 프로필에 적용",
"cancelledSummary": "{{total}}개 중 {{cancelled}}개의 실행이 취소됨",
"cancelled": "실행이 취소됨",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"vpnExtensionEntryNoVersion": " — {{capability}}, {{source}}",
"scanIncompleteMissing": "이 프로필은 아직 실행된 적이 없어 Donut이 관리하는 확장 프로그램만 확인할 수 있었습니다."
}
}
+11 -8
View File
@@ -2185,7 +2185,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "O Cookie Bot aquece seus perfis durante a noite em uma máquina remota, para que mantenham os cookies e o histórico sem o seu computador ligado. Requer um plano Pro ou Team."
"hint": "O Cookie Bot aquece seus perfis durante a noite em uma máquina remota, para que mantenham os cookies e o histórico sem o seu computador ligado. Requer um plano pago."
},
"empty": {
"title": "Nenhum perfil inscrito",
@@ -2456,7 +2456,7 @@
},
"actionBar": {
"enrol": "Inscrever no Cookie Bot",
"proRequired": "O Cookie Bot requer um plano Pro ou Team",
"proRequired": "O Cookie Bot requer um plano pago",
"noneEligible": "Nenhum dos perfis selecionados pode ser aquecido remotamente"
},
"actions": {
@@ -2517,15 +2517,18 @@
"titleBlocked": "Inicialização bloqueada",
"titleWarning": "Antes de iniciar",
"intro": "Revise estes problemas de \"{{name}}\" antes de iniciar o navegador.",
"fingerprintHeading": "A saída do proxy não corresponde à impressão digital",
"vpnExtensionHeading": "Extensão VPN detectada",
"fingerprintHeading": "A saída medida não corresponde à impressão digital",
"vpnExtensionHeading": "Extensão de VPN ou proxy detectada",
"vpnExtensionIntro": "Extensões neste perfil que podem redirecionar o tráfego do navegador:",
"vpnExtensionConfirmed": "Pode alterar o proxy",
"vpnExtensionLikely": "Talvez altere o proxy",
"vpnExtensionConfirmed": "Ferramenta de VPN ou proxy conhecida",
"vpnExtensionLikely": "Parece uma ferramenta de VPN ou proxy",
"vpnExtensionCapability": "Tem a permissão de proxy",
"vpnExtensionExplainer": "Se alguma delas redirecionar seu tráfego, a localização real do navegador deixará de corresponder ao fuso horário, ao idioma e à geolocalização com que este perfil foi criado, e o Donut não consegue detectar isso de fora.",
"proxyCapableHeading": "Extensões que podem alterar o proxy",
"proxyCapableIntro": "Não parecem VPNs, mas têm a permissão de proxy do Chromium, que gerenciadores de download e ferramentas de depuração também precisam. O Donut não consegue saber se alguma delas a está usando:",
"sourceDonut": "Gerenciada pelo Donut",
"sourceBrowser": "Instalada no perfil",
"measurementUnreliable": "Como uma extensão VPN pode substituir o proxy, a verificação de saída pode não refletir a rota que o navegador realmente usa.",
"measurementUnreliable": "Uma extensão neste perfil tem a permissão de proxy, então a verificação de saída pode não refletir a rota que o navegador realmente usa.",
"scanIncompleteEncrypted": "Este perfil está criptografado, portanto só foi possível verificar as extensões gerenciadas pelo Donut.",
"scanIncompleteEphemeral": "Este perfil ainda não tem dados, portanto só foi possível verificar as extensões gerenciadas pelo Donut.",
"scanIncompletePartial": "A verificação de extensões foi interrompida, então algumas podem não estar listadas.",
@@ -2535,8 +2538,8 @@
"dontWarnExtensions": "Não avisar novamente sobre estas extensões",
"applyToRemaining": "Aplicar esta escolha aos perfis restantes",
"cancelledSummary": "{{cancelled}} de {{total}} inicializações canceladas",
"cancelled": "Inicialização cancelada",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"vpnExtensionEntryNoVersion": " — {{capability}}, {{source}}",
"scanIncompleteMissing": "Este perfil ainda não foi iniciado, portanto só foi possível verificar as extensões gerenciadas pelo Donut."
}
}
+11 -8
View File
@@ -2192,7 +2192,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot прогревает ваши профили ночью на удалённой машине, чтобы они сохраняли cookies и историю, пока ваш компьютер выключен. Требуется тариф Pro или Team."
"hint": "Cookie Bot прогревает ваши профили ночью на удалённой машине, чтобы они сохраняли cookies и историю, пока ваш компьютер выключен. Требуется платный тариф."
},
"empty": {
"title": "Нет подключённых профилей",
@@ -2484,7 +2484,7 @@
},
"actionBar": {
"enrol": "Подключить к Cookie Bot",
"proRequired": "Для Cookie Bot нужен тариф Pro или Team",
"proRequired": "Для Cookie Bot нужен платный тариф",
"noneEligible": "Ни один из выбранных профилей нельзя прогреть удалённо"
},
"actions": {
@@ -2546,15 +2546,18 @@
"titleBlocked": "Запуск заблокирован",
"titleWarning": "Перед запуском",
"intro": "Проверьте эти проблемы профиля «{{name}}» перед запуском браузера.",
"fingerprintHeading": "Выходной узел прокси не совпадает с отпечатком",
"vpnExtensionHeading": "Обнаружено VPN-расширение",
"fingerprintHeading": "Измеренный выходной узел не совпадает с отпечатком",
"vpnExtensionHeading": "Обнаружено VPN- или прокси-расширение",
"vpnExtensionIntro": "Расширения в этом профиле, способные перенаправить трафик браузера:",
"vpnExtensionConfirmed": "Может изменить прокси",
"vpnExtensionLikely": "Возможно, изменит прокси",
"vpnExtensionConfirmed": "Известный VPN- или прокси-инструмент",
"vpnExtensionLikely": "Похоже на VPN- или прокси-инструмент",
"vpnExtensionCapability": "Имеет разрешение proxy",
"vpnExtensionExplainer": "Если одно из них направит трафик в другое место, реальное местоположение браузера перестанет совпадать с часовым поясом, языком и геолокацией, с которыми создавался профиль, а Donut не сможет это обнаружить извне.",
"proxyCapableHeading": "Расширения, способные изменить прокси",
"proxyCapableIntro": "Это не похоже на VPN, но у них есть разрешение proxy в Chromium, которое нужно и менеджерам загрузок, и инструментам отладки. Donut не может определить, использует ли его кто-то из них:",
"sourceDonut": "Управляется Donut",
"sourceBrowser": "Установлено в профиле",
"measurementUnreliable": "Поскольку VPN-расширение может переопределить прокси, проверка выходного узла может не отражать реальный маршрут браузера.",
"measurementUnreliable": "Расширение в этом профиле имеет разрешение proxy, поэтому проверка выходного узла может не отражать реальный маршрут браузера.",
"scanIncompleteEncrypted": "Профиль зашифрован, поэтому удалось проверить только расширения, управляемые Donut.",
"scanIncompleteEphemeral": "В профиле ещё нет данных, поэтому удалось проверить только расширения, управляемые Donut.",
"scanIncompletePartial": "Проверка расширений была прервана, поэтому некоторые могут отсутствовать в списке.",
@@ -2564,8 +2567,8 @@
"dontWarnExtensions": "Больше не предупреждать об этих расширениях",
"applyToRemaining": "Применить этот выбор к остальным профилям",
"cancelledSummary": "Отменено запусков: {{cancelled}} из {{total}}",
"cancelled": "Запуск отменён",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"vpnExtensionEntryNoVersion": " — {{capability}}, {{source}}",
"scanIncompleteMissing": "Профиль ещё ни разу не запускался, поэтому удалось проверить только расширения, управляемые Donut."
}
}
+11 -8
View File
@@ -2178,7 +2178,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot, profillerinizi gece boyunca uzak bir makinede ısıtır; böylece bilgisayarınız açık olmadan çerezlerini ve geçmişlerini korurlar. Pro veya Team planı gerekir."
"hint": "Cookie Bot, profillerinizi gece boyunca uzak bir makinede ısıtır; böylece bilgisayarınız açık olmadan çerezlerini ve geçmişlerini korurlar. Ücretli bir plan gerekir."
},
"empty": {
"title": "Kayıtlı profil yok",
@@ -2428,7 +2428,7 @@
},
"actionBar": {
"enrol": "Cookie Bot'a kaydet",
"proRequired": "Cookie Bot için Pro veya Team planı gerekir",
"proRequired": "Cookie Bot için ücretli bir plan gerekir",
"noneEligible": "Seçili profillerin hiçbiri uzaktan ısıtılamaz"
},
"actions": {
@@ -2488,15 +2488,18 @@
"titleBlocked": "Başlatma engellendi",
"titleWarning": "Başlatmadan önce",
"intro": "Tarayıcıyı başlatmadan önce \"{{name}}\" ile ilgili şu sorunları inceleyin.",
"fingerprintHeading": "Proxy çıkışı parmak iziyle eşleşmiyor",
"vpnExtensionHeading": "VPN uzantısı algılandı",
"fingerprintHeading": "Ölçülen çıkış parmak iziyle eşleşmiyor",
"vpnExtensionHeading": "VPN veya proxy uzantısı algılandı",
"vpnExtensionIntro": "Bu profildeki, tarayıcı trafiğini yeniden yönlendirebilecek uzantılar:",
"vpnExtensionConfirmed": "Proxy'yi değiştirebilir",
"vpnExtensionLikely": "Proxy'yi değiştirebilir (olası)",
"vpnExtensionConfirmed": "Bilinen VPN veya proxy aracı",
"vpnExtensionLikely": "VPN veya proxy aracı gibi görünüyor",
"vpnExtensionCapability": "Proxy iznine sahip",
"vpnExtensionExplainer": "Bunlardan biri trafiğinizi başka bir yere yönlendirirse, tarayıcının gerçek konumu artık bu profilin oluşturulduğu saat dilimi, dil ve coğrafi konumla eşleşmez ve Donut bunu dışarıdan algılayamaz.",
"proxyCapableHeading": "Proxy'yi değiştirebilen uzantılar",
"proxyCapableIntro": "Bunlar VPN'e benzemiyor, ancak Chromium'un proxy iznine sahipler; bu izne indirme yöneticileri ve hata ayıklama araçları da ihtiyaç duyar. Donut, herhangi birinin bunu kullanıp kullanmadığını anlayamaz:",
"sourceDonut": "Donut tarafından yönetiliyor",
"sourceBrowser": "Profile yüklenmiş",
"measurementUnreliable": "Bir VPN uzantısı proxy'yi geçersiz kılabileceğinden, çıkış kontrolü tarayıcının gerçekte kullandığı rotayı yansıtmayabilir.",
"measurementUnreliable": "Bu profildeki bir uzantı proxy iznine sahip, bu nedenle çıkış kontrolü tarayıcının gerçekte kullandığı rotayı yansıtmayabilir.",
"scanIncompleteEncrypted": "Bu profil şifreli olduğundan yalnızca Donut tarafından yönetilen uzantılar denetlenebildi.",
"scanIncompleteEphemeral": "Bu profilde henüz veri olmadığından yalnızca Donut tarafından yönetilen uzantılar denetlenebildi.",
"scanIncompletePartial": "Uzantı taraması yarıda kesildi, bu nedenle bazıları listelenmemiş olabilir.",
@@ -2506,8 +2509,8 @@
"dontWarnExtensions": "Bu uzantılar için bir daha uyarma",
"applyToRemaining": "Bu seçimi kalan profillere uygula",
"cancelledSummary": "{{total}} başlatmadan {{cancelled}} tanesi iptal edildi",
"cancelled": "Başlatma iptal edildi",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"vpnExtensionEntryNoVersion": " — {{capability}}, {{source}}",
"scanIncompleteMissing": "Bu profil henüz başlatılmadığından yalnızca Donut tarafından yönetilen uzantılar denetlenebildi."
}
}
+11 -8
View File
@@ -2178,7 +2178,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot làm ấm hồ sơ của bạn qua đêm trên máy từ xa, giúp chúng giữ được cookie và lịch sử mà không cần bật máy tính của bạn. Cần gói Pro hoặc Team."
"hint": "Cookie Bot làm ấm hồ sơ của bạn qua đêm trên máy từ xa, giúp chúng giữ được cookie và lịch sử mà không cần bật máy tính của bạn. Cần gói trả phí."
},
"empty": {
"title": "Chưa có hồ sơ nào được đăng ký",
@@ -2428,7 +2428,7 @@
},
"actionBar": {
"enrol": "Đăng ký vào Cookie Bot",
"proRequired": "Cookie Bot cần gói Pro hoặc Team",
"proRequired": "Cookie Bot cần gói trả phí",
"noneEligible": "Không hồ sơ nào đã chọn có thể làm ấm từ xa"
},
"actions": {
@@ -2488,15 +2488,18 @@
"titleBlocked": "Đã chặn khởi chạy",
"titleWarning": "Trước khi khởi chạy",
"intro": "Hãy xem lại các vấn đề của \"{{name}}\" trước khi khởi động trình duyệt.",
"fingerprintHeading": "Điểm ra của proxy không khớp với dấu vân tay",
"vpnExtensionHeading": "Đã phát hiện tiện ích VPN",
"fingerprintHeading": "Điểm ra đo được không khớp với dấu vân tay",
"vpnExtensionHeading": "Đã phát hiện tiện ích VPN hoặc proxy",
"vpnExtensionIntro": "Các tiện ích trong hồ sơ này có thể định tuyến lại lưu lượng của trình duyệt:",
"vpnExtensionConfirmed": "Có thể thay đổi proxy",
"vpnExtensionLikely": "Có khả năng thay đổi proxy",
"vpnExtensionConfirmed": "Công cụ VPN hoặc proxy đã biết",
"vpnExtensionLikely": "Có vẻ là công cụ VPN hoặc proxy",
"vpnExtensionCapability": "Có quyền proxy",
"vpnExtensionExplainer": "Nếu một trong số đó chuyển lưu lượng của bạn đi nơi khác, vị trí thực của trình duyệt sẽ không còn khớp với múi giờ, ngôn ngữ và vị trí địa lý mà hồ sơ này được tạo ra, và Donut không thể phát hiện điều đó từ bên ngoài.",
"proxyCapableHeading": "Các tiện ích có thể thay đổi proxy",
"proxyCapableIntro": "Chúng không giống VPN, nhưng có quyền proxy của Chromium, thứ mà trình quản lý tải xuống và công cụ gỡ lỗi cũng cần. Donut không thể biết liệu có tiện ích nào đang dùng quyền đó hay không:",
"sourceDonut": "Do Donut quản lý",
"sourceBrowser": "Đã cài trong hồ sơ",
"measurementUnreliable": " tiện ích VPN có thể ghi đè proxy, kết quả kiểm tra điểm ra có thể không phản ánh tuyến đường mà trình duyệt thực sự dùng.",
"measurementUnreliable": "Một tiện ích trong hồ sơ này có quyền proxy, nên kết quả kiểm tra điểm ra có thể không phản ánh tuyến đường mà trình duyệt thực sự dùng.",
"scanIncompleteEncrypted": "Hồ sơ này được mã hóa nên chỉ có thể kiểm tra các tiện ích do Donut quản lý.",
"scanIncompleteEphemeral": "Hồ sơ này chưa có dữ liệu nên chỉ có thể kiểm tra các tiện ích do Donut quản lý.",
"scanIncompletePartial": "Quá trình quét tiện ích bị ngắt giữa chừng nên có thể thiếu một số tiện ích.",
@@ -2506,8 +2509,8 @@
"dontWarnExtensions": "Không cảnh báo lại về các tiện ích này",
"applyToRemaining": "Áp dụng lựa chọn này cho các hồ sơ còn lại",
"cancelledSummary": "Đã hủy {{cancelled}} trên {{total}} lượt khởi chạy",
"cancelled": "Đã hủy khởi chạy",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"vpnExtensionEntryNoVersion": " — {{capability}}, {{source}}",
"scanIncompleteMissing": "Hồ sơ này chưa từng được khởi chạy nên chỉ có thể kiểm tra các tiện ích do Donut quản lý."
}
}
+11 -8
View File
@@ -2178,7 +2178,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot 在远程机器上通宵养号,无需开着你的电脑也能保住 Cookie 和历史记录。需要 Pro 或 Team 套餐。"
"hint": "Cookie Bot 在远程机器上通宵养号,无需开着你的电脑也能保住 Cookie 和历史记录。需要付费套餐。"
},
"empty": {
"title": "尚未加入任何配置文件",
@@ -2428,7 +2428,7 @@
},
"actionBar": {
"enrol": "加入 Cookie Bot",
"proRequired": "Cookie Bot 需要 Pro 或 Team 套餐",
"proRequired": "Cookie Bot 需要付费套餐",
"noneEligible": "所选配置文件都无法远程养号"
},
"actions": {
@@ -2488,15 +2488,18 @@
"titleBlocked": "启动已阻止",
"titleWarning": "启动前请注意",
"intro": "启动浏览器前,请检查“{{name}}”的以下问题。",
"fingerprintHeading": "代理出口与指纹不匹配",
"vpnExtensionHeading": "检测到 VPN 扩展",
"fingerprintHeading": "测得的出口与指纹不匹配",
"vpnExtensionHeading": "检测到 VPN 或代理扩展",
"vpnExtensionIntro": "此配置文件中可能改变浏览器流量路径的扩展:",
"vpnExtensionConfirmed": "可以更改代理",
"vpnExtensionLikely": "可能会更改代理",
"vpnExtensionConfirmed": "已知的 VPN 或代理工具",
"vpnExtensionLikely": "疑似 VPN 或代理工具",
"vpnExtensionCapability": "拥有代理权限",
"vpnExtensionExplainer": "如果其中之一将流量转发到别处,浏览器的真实位置将不再与创建此配置文件时使用的时区、语言和地理位置一致,而 Donut 无法从外部察觉。",
"proxyCapableHeading": "可以更改代理的扩展",
"proxyCapableIntro": "它们看起来不是 VPN,但拥有 Chromium 的代理权限,下载管理器和调试工具同样需要该权限。Donut 无法判断它们是否在使用它:",
"sourceDonut": "由 Donut 管理",
"sourceBrowser": "已安装在配置文件中",
"measurementUnreliable": "由于 VPN 扩展可以覆盖代理设置,出口检测结果可能并非浏览器实际使用的线路。",
"measurementUnreliable": "此配置文件中有扩展拥有代理权限,因此出口检测结果可能并非浏览器实际使用的线路。",
"scanIncompleteEncrypted": "此配置文件已加密,因此只能检查由 Donut 管理的扩展。",
"scanIncompleteEphemeral": "此配置文件尚无数据,因此只能检查由 Donut 管理的扩展。",
"scanIncompletePartial": "扩展扫描被中断,可能有部分扩展未列出。",
@@ -2506,8 +2509,8 @@
"dontWarnExtensions": "不再就这些扩展发出警告",
"applyToRemaining": "将此选择应用于其余配置文件",
"cancelledSummary": "已取消 {{total}} 次启动中的 {{cancelled}} 次",
"cancelled": "已取消启动",
"vpnExtensionEntry": " {{version}} — {{capability}}、{{source}}",
"vpnExtensionEntryNoVersion": " — {{capability}}、{{source}}",
"scanIncompleteMissing": "此配置文件尚未启动过,因此只能检查由 Donut 管理的扩展。"
}
}
+29 -3
View File
@@ -1176,11 +1176,33 @@ export function clearThemeColors(): void {
});
}
/**
* WebKitGTK, which is the webview on Linux and nowhere else.
*
* Windows runs WebView2 (a Chromium user agent) and macOS runs WKWebView
* (`Macintosh`), so an `AppleWebKit` user agent claiming X11/Linux is
* WebKitGTK and only WebKitGTK.
*/
function isWebKitGtk(): boolean {
if (typeof navigator === "undefined") {
return false;
}
const ua = navigator.userAgent;
return /\b(?:X11|Linux)\b/.test(ua) && ua.includes("AppleWebKit");
}
/**
* Run a theme mutation inside a View Transition so the whole UI cross-fades
* (~200ms, tuned in globals.css) instead of hard-cutting between palettes.
* Falls back to an instant switch when the API is unavailable or the user
* prefers reduced motion.
* Falls back to an instant switch when the API is unavailable, the user
* prefers reduced motion, or the webview is WebKitGTK.
*
* A view transition asks the engine to snapshot the whole document into
* compositor layers and hold rendering until it can cross-fade them. That is
* the newest and least-exercised path in WebKitGTK, and it is reached from
* exactly one screen here, which is the screen a Linux user reported the app
* segfaulting on. The cross-fade is decoration; not taking that path on Linux
* costs nothing anyone will miss.
*/
export function withThemeTransition(mutate: () => void): void {
if (typeof document === "undefined") {
@@ -1193,7 +1215,11 @@ export function withThemeTransition(mutate: () => void): void {
const doc = document as Document & {
startViewTransition?: (callback: () => void) => unknown;
};
if (reduced || typeof doc.startViewTransition !== "function") {
if (
reduced ||
isWebKitGtk() ||
typeof doc.startViewTransition !== "function"
) {
mutate();
return;
}
+20 -4
View File
@@ -673,7 +673,22 @@ export interface ConsistencyResult {
mismatches: string[];
}
/** A VPN/proxy extension found in a profile, which can reroute browser traffic. */
/**
* How strongly an extension is believed to be a VPN or proxy tool.
* "capability" is not such a claim: it means only that the extension holds
* Chromium's `proxy` permission, which download managers do too.
*/
export type VpnExtensionConfidence = "confirmed" | "likely" | "capability";
/** How much of a profile's extension set could be read. */
export type ExtensionScanState =
| "scanned"
| "partial"
| "encrypted"
| "ephemeral"
| "missing";
/** An extension found in a profile that could change where the browser connects. */
export interface DetectedVpnExtension {
/** Acknowledgement identity: `donut:<uuid>` or `crx:<id>`. */
key: string;
@@ -681,15 +696,16 @@ export interface DetectedVpnExtension {
version: string | null;
/** "donut" (managed by Donut) or "browser" (installed in the profile). */
source: string;
/** "confirmed" (holds the proxy permission) or "likely". */
confidence: string;
confidence: VpnExtensionConfidence;
/** Holds the `proxy` permission outright, so it can change the proxy today. */
proxy_control: boolean;
signals: string[];
}
/** Local-only checks answered before a launch starts any worker. */
export interface PreLaunchChecks {
vpn_extensions: DetectedVpnExtension[];
scan_state: string;
scan_state: ExtensionScanState;
consistency: ConsistencyResult;
exit_probe_pending: boolean;
exit_measurement_unreliable: boolean;