Compare commits

...
Author SHA1 Message Date
zhom e6ecc44686 chore: version bump 2026-08-16 20:00:33 +04:00
zhom d7f002d8ac feat: extension export via api 2026-08-16 19:50:12 +04:00
zhom 2be0d4df0b refactor: better proxy clipboard autofill 2026-08-16 19:49:54 +04:00
zhom a0175eab0d refactor: improve ephemeral ux 2026-08-16 17:22:43 +04:00
zhom 1a36fb9c12 refactor: store logs and window state inside portable build 2026-08-16 07:48:36 +04:00
github-actions[bot]andgithub-actions[bot] <github-actions[bot]@users.noreply.github.com> e78f3e7c76 chore: update flake.nix for v0.29.4 [skip ci] (#561)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-15 23:54:55 +00:00
github-actions[bot]andgithub-actions[bot] <github-actions[bot]@users.noreply.github.com> bcb616d083 docs: update CHANGELOG.md and README.md for v0.29.4 [skip ci] (#560)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-15 23:54:33 +00:00
zhom 7b09260926 chore: version bump 2026-08-16 02:14:32 +04:00
zhom 927fe37cda refactor: cleanup 2026-08-16 02:09:27 +04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c07039e0a6 ci(deps): bump the github-actions group with 4 updates (#559)
Bumps the github-actions group with 4 updates: [anomalyco/opencode/github](https://github.com/anomalyco/opencode), [google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml](https://github.com/google/osv-scanner-action), [google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml](https://github.com/google/osv-scanner-action) and [swatinem/rust-cache](https://github.com/swatinem/rust-cache).


Updates `anomalyco/opencode/github` from 1.18.14 to 1.18.17
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/65cf14df16c191f3e9684f0d9a8bae69103ced6d...02546dfc2e4515a4f90aaf9ceb3890df2ac2b479)

Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml` from 2.3.8 to 2.5.0
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/9a498708959aeaef5ef730655706c5a1df1edbc2...8deb546fdb875b9996d27d4950be7312dac076a1)

Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml` from 2.3.8 to 2.5.0
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/9a498708959aeaef5ef730655706c5a1df1edbc2...8deb546fdb875b9996d27d4950be7312dac076a1)

Updates `swatinem/rust-cache` from 2.9.1 to 2.9.2
- [Release notes](https://github.com/swatinem/rust-cache/releases)
- [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/swatinem/rust-cache/compare/c19371144df3bb44fab255c43d04cbc2ab54d1c4...6323deb102c322ba6fcbdcafc7e3dddab59af2b6)

---
updated-dependencies:
- dependency-name: anomalyco/opencode/github
  dependency-version: 1.18.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml
  dependency-version: 2.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml
  dependency-version: 2.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: swatinem/rust-cache
  dependency-version: 2.9.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-15 09:07:59 +00:00
github-actions[bot]andgithub-actions[bot] <github-actions[bot]@users.noreply.github.com> df5ece8e2d chore: update flake.nix for v0.29.3 [skip ci] (#557)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-12 12:18:14 +00:00
github-actions[bot]andgithub-actions[bot] <github-actions[bot]@users.noreply.github.com> d3734ef876 docs: update CHANGELOG.md and README.md for v0.29.3 [skip ci] (#556)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-12 12:17:55 +00:00
andy b446e20350 Merge pull request #555 from zhom/contributors-readme-action-fg8R0XTOCb
docs(contributor): contributors readme action update
2026-08-12 03:38:04 -07:00
github-actions[bot] 2ccf23eea3 docs(contributor): contrib-readme-action has updated readme 2026-08-12 10:36:47 +00:00
zhom e20c454ff2 chore: update pnpm 2026-08-12 14:36:08 +04:00
zhom 57dbf51372 chore: version bump 2026-08-12 14:35:57 +04:00
zhom ad1aa6cc5a refactor: minor improvement 2026-08-12 14:35:57 +04:00
github-actions[bot]andgithub-actions[bot] <github-actions[bot]@users.noreply.github.com> 01e4afb687 chore: update flake.nix for v0.29.2 [skip ci] (#552)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-10 21:56:31 +00:00
github-actions[bot]andgithub-actions[bot] <github-actions[bot]@users.noreply.github.com> 4e52d9bc09 docs: update CHANGELOG.md and README.md for v0.29.2 [skip ci] (#551)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-10 21:56:17 +00:00
zhom b1c4559b74 chore: linting 2026-08-11 00:18:49 +04:00
zhom 5afde36790 chore: version bump 2026-08-10 21:25:22 +04:00
zhom 325d8fae31 refactor: cleanup sync 2026-08-10 15:47:31 +04:00
zhom 929f5a0ead chore: linting 2026-08-10 09:31:42 +04:00
zhom 32a1728dee refactor: profile imports 2026-08-10 09:19:25 +04:00
github-actions[bot]andgithub-actions[bot] <github-actions[bot]@users.noreply.github.com> a6b79341b3 chore: update flake.nix for v0.29.1 [skip ci] (#546)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-08 21:25:33 +00:00
github-actions[bot]andgithub-actions[bot] <github-actions[bot]@users.noreply.github.com> a6b4108d82 docs: update CHANGELOG.md and README.md for v0.29.1 [skip ci] (#545)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-08 21:25:11 +00:00
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]andgithub-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]andgithub-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
zhom 3b1feb3f1b chore: version bump 2026-08-08 16:35:46 +04:00
github-actions[bot]andgithub-actions[bot] <github-actions[bot]@users.noreply.github.com> bc2b93d902 docs: update CHANGELOG.md and README.md for v0.29.0 [skip ci] (#539)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-08 12:26:50 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 5c24e84eaf ci(deps): bump the github-actions group with 3 updates (#538)
Bumps the github-actions group with 3 updates: [pnpm/action-setup](https://github.com/pnpm/action-setup), [anomalyco/opencode/github](https://github.com/anomalyco/opencode) and [crate-ci/typos](https://github.com/crate-ci/typos).


Updates `pnpm/action-setup` from 6.0.9 to 6.0.10
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/0ebf47130e4866e96fce0953f49152a61190b271...0977fd99725f1db4007ccb2928dbb4e90d06cc86)

Updates `anomalyco/opencode/github` from 1.18.9 to 1.18.14
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/4da7bb44c84e013fa53e9c5d02ac753d1435c81a...65cf14df16c191f3e9684f0d9a8bae69103ced6d)

Updates `crate-ci/typos` from 1.48.0 to 1.49.0
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/bee27e3a4fd1ea2111cf90ab89cd076c870fce14...8a48f81b6c64dcfea44b3633223084c4be58ac5f)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: anomalyco/opencode/github
  dependency-version: 1.18.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 09:08:52 +00:00
98 changed files with 13109 additions and 1567 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
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
with:
run_install: false
+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
+41 -7
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' &&
@@ -659,7 +693,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Run opencode
uses: anomalyco/opencode/github@4da7bb44c84e013fa53e9c5d02ac753d1435c81a #v1.18.9
uses: anomalyco/opencode/github@02546dfc2e4515a4f90aaf9ceb3890df2ac2b479 #v1.18.17
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
with:
run_install: false
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
with:
run_install: false
+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'
+2 -2
View File
@@ -46,7 +46,7 @@ jobs:
scan-scheduled:
name: Scheduled Security Scan
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
with:
scan-args: |-
-r
@@ -58,7 +58,7 @@ jobs:
scan-pr:
name: PR Security Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
with:
scan-args: |-
-r
+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
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
security-scan:
name: Security Vulnerability Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
with:
scan-args: |-
-r
+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'
+3 -3
View File
@@ -20,7 +20,7 @@ jobs:
security-scan:
if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
with:
scan-args: |-
-r
@@ -104,7 +104,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
with:
run_install: false
@@ -127,7 +127,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config unzip xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 #v2.9.2
with:
workdir: ./src-tauri
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
security-scan:
if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
with:
scan-args: |-
-r
@@ -111,7 +111,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
with:
run_install: false
@@ -134,7 +134,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config unzip xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 #v2.9.2
with:
workdir: ./src-tauri
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
- name: Checkout Actions Repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Spell Check Repo
uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 #v1.48.0
uses: crate-ci/typos@8a48f81b6c64dcfea44b3633223084c4be58ac5f #v1.49.0
+196
View File
@@ -1,6 +1,202 @@
# Changelog
## v0.29.4 (2026-08-15)
### Refactoring
- cleanup
### Documentation
- update CHANGELOG.md and README.md for v0.29.3 [skip ci] (#556)
### Maintenance
- chore: version bump
- ci(deps): bump the github-actions group with 4 updates (#559)
- chore: update flake.nix for v0.29.3 [skip ci] (#557)
## v0.29.3 (2026-08-12)
### Refactoring
- minor improvement
### Maintenance
- chore: update pnpm
- chore: version bump
- chore: update flake.nix for v0.29.2 [skip ci] (#552)
## v0.29.2 (2026-08-10)
### Refactoring
- cleanup sync
- profile imports
### Maintenance
- chore: linting
- chore: version bump
- chore: linting
- chore: update flake.nix for v0.29.1 [skip ci] (#546)
## v0.29.1 (2026-08-08)
### Bug Fixes
- prevent settings page from crashing on some systems
### Refactoring
- update logic and locks around vpn extensions
### Maintenance
- chore: linting
- chore: update pnpm
- chore: switch to ai-inference v3 and fail workflows on 410
- chore: version bump
- chore: update flake.nix for v0.29.0 [skip ci] (#542)
## 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
- 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
- contrib-readme-action has updated readme
- contrib-readme-action has updated readme
### Maintenance
- 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.28.2 (2026-07-12)
### Features
+8 -8
View File
@@ -46,7 +46,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_x64.dmg) |
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_x64.dmg) |
Or install via Homebrew:
@@ -56,15 +56,15 @@ brew install --cask donut
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_x64-portable.zip)
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut-0.28.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut-0.28.2-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.AppImage) |
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut-0.29.4-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut-0.29.4-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
@@ -131,10 +131,10 @@ Donut Browser is built by the people who use it, and plenty of the most useful h
</a>
</td>
<td align="center">
<a href="https://github.com/HassiyYT">
<img src="https://avatars.githubusercontent.com/u/81773493?v=4" width="100;" alt="HassiyYT"/>
<a href="https://github.com/kalinichaa">
<img src="https://avatars.githubusercontent.com/u/81773493?v=4" width="100;" alt="kalinichaa"/>
<br />
<sub><b>Hassiy</b></sub>
<sub><b>Alexander Kalinich</b></sub>
</a>
</td>
<td align="center">
+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"
+9
View File
@@ -13,3 +13,12 @@ S3_ACCESS_KEY_ID=CHANGE_ME
S3_SECRET_ACCESS_KEY=CHANGE_ME
S3_BUCKET=donut-sync
S3_FORCE_PATH_STYLE=true
# The address Donut Browser is sent to for file transfers. Set this whenever
# S3_ENDPOINT is only reachable from the server — running MinIO in the same
# compose file makes S3_ENDPOINT a container name like http://minio:9000, which
# resolves on the container network and nowhere else. Presigned URLs are signed
# against the host they name, so leaving this unset there hands every client a
# URL it cannot open: /health and /readyz stay green while every transfer fails.
# Defaults to S3_ENDPOINT, which is correct when storage is already public.
# S3_PUBLIC_ENDPOINT=https://storage.example.com
+13 -3
View File
@@ -19,15 +19,25 @@ export class AppController {
return { status: "ok" };
}
// `storageEndpoint` is the host clients are handed in presigned URLs. The
// server cannot tell whether a client can reach it, so report it and let
// whoever is debugging a failing sync compare it against their network.
// Self-hosted only — see getDiagnosticStorageEndpoint.
@Get("readyz")
async getReadiness(): Promise<{ status: string; s3: boolean }> {
async getReadiness(): Promise<{
status: string;
s3: boolean;
storageEndpoint?: string;
}> {
const s3Ready = await this.syncService.checkS3Connectivity();
const storageEndpoint = this.syncService.getDiagnosticStorageEndpoint();
const diagnostic = storageEndpoint ? { storageEndpoint } : {};
if (!s3Ready) {
throw new HttpException(
{ status: "not ready", s3: false },
{ status: "not ready", s3: false, ...diagnostic },
HttpStatus.SERVICE_UNAVAILABLE,
);
}
return { status: "ready", s3: true };
return { status: "ready", s3: true, ...diagnostic };
}
}
+79 -8
View File
@@ -82,6 +82,10 @@ function sanitizeMetadata(
export class SyncService implements OnModuleInit {
private readonly logger = new Logger(SyncService.name);
private s3Client: S3Client;
// Signs the URLs handed to clients. Same instance as `s3Client` unless
// `S3_PUBLIC_ENDPOINT` names a different, client-reachable address.
private presignClient: S3Client;
private publicEndpoint: string;
private bucket: string;
// Upper bound on presign batch array length (DoS guard).
private static readonly MAX_BATCH_ITEMS = 1000;
@@ -112,16 +116,34 @@ export class SyncService implements OnModuleInit {
this.bucket = requireEnv("S3_BUCKET");
const credentials = { accessKeyId, secretAccessKey };
this.s3Client = new S3Client({
endpoint,
region,
credentials: {
accessKeyId,
secretAccessKey,
},
credentials,
forcePathStyle,
});
// Presigned URLs are handed to a desktop client on another machine, so they
// must name a host that client can reach. `S3_ENDPOINT` is often reachable
// only from the server: the documented compose file points it at
// `http://minio:9000`, a Docker service name that resolves on the compose
// network and nowhere else. Signing is bound to the host, so the presign
// client is a second client pinned to the public address rather than a
// string rewrite of the signed URL.
const publicEndpoint =
this.configService.get<string>("S3_PUBLIC_ENDPOINT") || endpoint;
this.publicEndpoint = publicEndpoint;
this.presignClient =
publicEndpoint === endpoint
? this.s3Client
: new S3Client({
endpoint: publicEndpoint,
region,
credentials,
forcePathStyle,
});
this.backendInternalUrl = this.configService.get<string>(
"BACKEND_INTERNAL_URL",
);
@@ -132,6 +154,51 @@ export class SyncService implements OnModuleInit {
async onModuleInit() {
await this.ensureBucketExists();
this.warnIfPresignEndpointIsServerOnly();
}
/**
* The address clients are sent to for object transfers, for `/readyz` to
* report when a self-hoster is debugging a failing sync.
*
* Withheld in cloud mode: `/readyz` is unauthenticated, and a managed
* deployment should not publish its storage host to anyone who can reach the
* probe. Self-hosters own both ends, and the value is the whole point of the
* diagnostic there.
*/
getDiagnosticStorageEndpoint(): string | undefined {
const isCloud = Boolean(
this.configService.get<string>("SYNC_JWT_PUBLIC_KEY"),
);
return isCloud ? undefined : this.publicEndpoint;
}
/**
* A single-label host (`minio`, `s3`) only resolves inside the container
* network, so every presigned URL built from it is unreachable for the
* desktop client even though the server's own S3 calls succeed. That failure
* shows up as healthy `/health` and `/readyz` with every file transfer
* failing at connect, which is near-impossible to diagnose from the client.
* Say it once at boot instead.
*/
private warnIfPresignEndpointIsServerOnly(): void {
let host: string;
try {
host = new URL(this.publicEndpoint).hostname;
} catch {
return;
}
const isSingleLabel =
!host.includes(".") && !host.includes(":") && host !== "localhost";
if (!isSingleLabel) return;
this.logger.warn(
`Storage endpoint '${this.publicEndpoint}' uses the container-only host '${host}'. ` +
"Presigned URLs built from it cannot be reached by Donut Browser, so every " +
"transfer will fail while /health and /readyz stay green. Set S3_PUBLIC_ENDPOINT " +
"to an address your devices can reach (and publish that port).",
);
}
private async ensureBucketExists(): Promise<void> {
@@ -332,7 +399,7 @@ export class SyncService implements OnModuleInit {
const metadataHeaders = new Set(
Object.keys(metadata ?? {}).map((name) => `x-amz-meta-${name}`),
);
const url = await getSignedUrl(this.s3Client, command, {
const url = await getSignedUrl(this.presignClient, command, {
expiresIn,
// The AWS presigner otherwise hoists user metadata into the query string.
// The client echoes the response metadata as headers, so those headers
@@ -374,7 +441,7 @@ export class SyncService implements OnModuleInit {
Key: key,
});
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
const url = await getSignedUrl(this.presignClient, command, { expiresIn });
return {
url,
@@ -505,7 +572,9 @@ export class SyncService implements OnModuleInit {
ContentType: item.contentType || "application/octet-stream",
});
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
const url = await getSignedUrl(this.presignClient, command, {
expiresIn,
});
return {
key: item.key,
@@ -565,7 +634,9 @@ export class SyncService implements OnModuleInit {
Key: key,
});
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
const url = await getSignedUrl(this.presignClient, command, {
expiresIn,
});
return {
key: rawKey,
@@ -0,0 +1,201 @@
import { INestApplication } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import request from "supertest";
import { App } from "supertest/types";
import { AppController } from "./../src/app.controller.js";
import { AppService } from "./../src/app.service.js";
import { SyncModule } from "./../src/sync/sync.module.js";
import {
configureTestEnv,
TEST_S3_ENDPOINT,
TEST_SYNC_TOKEN,
waitForTestS3,
} from "./test-env.js";
// Presigning is offline, so this host never has to accept a connection — the
// assertions are about which host ends up in the signed URL.
const PUBLIC_ENDPOINT = "https://storage.example.com";
// Only needs to be present for the server to consider itself cloud-mode; no
// token is verified against it in these assertions.
const CLOUD_PUBLIC_KEY =
"-----BEGIN PUBLIC KEY-----\nnot-a-real-key\n-----END PUBLIC KEY-----";
interface PresignResponse {
url: string;
}
interface PresignBatchResponse {
items: Array<{ key: string; url: string }>;
}
interface ReadyResponse {
status: string;
s3: boolean;
storageEndpoint: string;
}
async function bootstrap(publicEndpoint: string | undefined) {
configureTestEnv();
if (publicEndpoint) {
process.env.S3_PUBLIC_ENDPOINT = publicEndpoint;
} else {
delete process.env.S3_PUBLIC_ENDPOINT;
}
await waitForTestS3();
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ isGlobal: true }), SyncModule],
controllers: [AppController],
providers: [AppService],
}).compile();
const app = moduleFixture.createNestApplication<INestApplication<App>>();
await app.listen(0);
return app;
}
// A self-hosted server usually reaches its storage over a private address the
// desktop client has no route to. Signing client URLs against that address
// handed every client a URL it could not open, so uploads failed at connect
// while /health and /readyz stayed green.
describe("presigned URL host", () => {
describe("with S3_PUBLIC_ENDPOINT set", () => {
let app: INestApplication<App>;
beforeAll(async () => {
app = await bootstrap(PUBLIC_ENDPOINT);
});
afterAll(async () => {
delete process.env.S3_PUBLIC_ENDPOINT;
await app.close();
});
it("signs single upload URLs against the public endpoint", async () => {
const response = await request(app.getHttpServer())
.post("/v1/objects/presign-upload")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: "endpoint/single.txt" })
.expect(200);
const { url } = response.body as PresignResponse;
expect(url.startsWith(PUBLIC_ENDPOINT)).toBe(true);
expect(url).not.toContain(TEST_S3_ENDPOINT);
});
it("signs batch upload URLs against the public endpoint", async () => {
const response = await request(app.getHttpServer())
.post("/v1/objects/presign-upload-batch")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ items: [{ key: "endpoint/a.txt" }, { key: "endpoint/b.txt" }] })
.expect(200);
const { items } = response.body as PresignBatchResponse;
expect(items).toHaveLength(2);
for (const item of items) {
expect(item.url.startsWith(PUBLIC_ENDPOINT)).toBe(true);
}
});
it("signs download URLs against the public endpoint", async () => {
const response = await request(app.getHttpServer())
.post("/v1/objects/presign-download")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: "endpoint/single.txt" })
.expect(200);
const { url } = response.body as PresignResponse;
expect(url.startsWith(PUBLIC_ENDPOINT)).toBe(true);
});
// The server's own S3 calls must keep using the private endpoint, or
// pointing clients at a public address would break the server itself.
it("still reaches storage over the private endpoint", async () => {
const response = await request(app.getHttpServer())
.post("/v1/objects/stat")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: "endpoint/does-not-exist" })
.expect(200);
expect(response.body).toEqual({ exists: false });
});
it("reports the client-facing endpoint from /readyz", async () => {
const response = await request(app.getHttpServer())
.get("/readyz")
.expect(200);
const body = response.body as ReadyResponse;
expect(body.s3).toBe(true);
expect(body.storageEndpoint).toBe(PUBLIC_ENDPOINT);
});
});
// /readyz has no auth, so a managed deployment must not publish its storage
// host to anyone who can reach the probe.
describe("in cloud mode", () => {
let app: INestApplication<App>;
const previousKey = process.env.SYNC_JWT_PUBLIC_KEY;
beforeAll(async () => {
process.env.SYNC_JWT_PUBLIC_KEY = CLOUD_PUBLIC_KEY;
app = await bootstrap(PUBLIC_ENDPOINT);
});
afterAll(async () => {
if (previousKey === undefined) {
delete process.env.SYNC_JWT_PUBLIC_KEY;
} else {
process.env.SYNC_JWT_PUBLIC_KEY = previousKey;
}
delete process.env.S3_PUBLIC_ENDPOINT;
await app.close();
});
it("withholds the storage endpoint from /readyz", async () => {
const response = await request(app.getHttpServer())
.get("/readyz")
.expect(200);
const body = response.body as ReadyResponse;
expect(body.s3).toBe(true);
expect(body.storageEndpoint).toBeUndefined();
expect(JSON.stringify(body)).not.toContain("storage.example.com");
});
});
describe("without S3_PUBLIC_ENDPOINT", () => {
let app: INestApplication<App>;
beforeAll(async () => {
app = await bootstrap(undefined);
});
afterAll(async () => {
await app.close();
});
it("falls back to S3_ENDPOINT", async () => {
const response = await request(app.getHttpServer())
.post("/v1/objects/presign-upload")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: "endpoint/fallback.txt" })
.expect(200);
const { url } = response.body as PresignResponse;
expect(url.startsWith(TEST_S3_ENDPOINT)).toBe(true);
});
it("reports the fallback endpoint from /readyz", async () => {
const response = await request(app.getHttpServer())
.get("/readyz")
.expect(200);
expect((response.body as ReadyResponse).storageEndpoint).toBe(
TEST_S3_ENDPOINT,
);
});
});
});
+78 -3
View File
@@ -680,6 +680,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.4.2"
@@ -967,6 +976,15 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "cbc"
version = "0.2.1"
@@ -1785,7 +1803,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.28.2"
version = "0.29.4"
dependencies = [
"aes 0.9.1",
"aes-gcm 0.11.0",
@@ -1797,7 +1815,7 @@ dependencies = [
"blake3",
"boringtun",
"bzip2",
"cbc",
"cbc 0.2.1",
"chrono",
"chrono-tz",
"clap",
@@ -1832,6 +1850,8 @@ dependencies = [
"resvg",
"ring",
"rusqlite",
"secret-service",
"security-framework",
"serde",
"serde_json",
"serde_yaml",
@@ -3379,6 +3399,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding 0.3.3",
"generic-array",
]
@@ -3388,7 +3409,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [
"block-padding",
"block-padding 0.4.2",
"hybrid-array",
]
@@ -4085,6 +4106,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
@@ -4095,6 +4130,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -4121,6 +4165,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
@@ -5713,6 +5767,25 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "secret-service"
version = "5.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14"
dependencies = [
"aes 0.8.4",
"cbc 0.1.2",
"futures-util",
"generic-array",
"getrandom 0.2.17",
"hkdf",
"num",
"once_cell",
"serde",
"sha2 0.10.9",
"zbus",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -7198,6 +7271,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
]
@@ -8985,6 +9059,7 @@ dependencies = [
"rustix",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",
+2
View File
@@ -87,7 +87,9 @@ export const commandCoverage = {
"list_extensions",
"get_extension_icon",
"add_extension",
"add_unpacked_extension",
"update_extension",
"update_extension_from_path",
"delete_extension",
"list_extension_groups",
"create_extension_group",
+51
View File
@@ -68,6 +68,7 @@ export class AppSession {
extraEnv = {},
args = [],
seedVersionCache = true,
seedDownloadedBrowser = false,
onboardingCompleted = true,
wayfernTermsAccepted = true,
}) {
@@ -80,6 +81,7 @@ export class AppSession {
this.extraEnv = extraEnv;
this.args = args;
this.seedVersionCache = seedVersionCache;
this.seedDownloadedBrowser = seedDownloadedBrowser;
this.onboardingCompleted = onboardingCompleted;
this.wayfernTermsAccepted = wayfernTermsAccepted;
this.session = null;
@@ -184,6 +186,54 @@ export class AppSession {
}
});
}
if (this.seedDownloadedBrowser) {
// Registers a Wayfern version as "downloaded" without installing a
// binary. Profile import derives its version from this registry and
// fails with BROWSER_NOT_DOWNLOADED otherwise, so suites that exercise
// import but never launch a browser need the entry and nothing else.
const seededVersion =
typeof this.seedDownloadedBrowser === "string"
? this.seedDownloadedBrowser
: "150.0.7871.100";
const installDir = path.join(
this.dataRoot,
"data",
"binaries",
"wayfern",
seededVersion,
);
await mkdir(installDir, { recursive: true });
const registryPath = path.join(
this.dataRoot,
"data",
"data",
"downloaded_browsers.json",
);
await mkdir(path.dirname(registryPath), { recursive: true });
await writeFile(
registryPath,
`${JSON.stringify(
{
browsers: {
wayfern: {
[seededVersion]: {
browser: "wayfern",
version: seededVersion,
file_path: installDir,
},
},
},
},
null,
2,
)}\n`,
{ flag: "wx" },
).catch((error) => {
if (error.code !== "EEXIST") {
throw error;
}
});
}
const env = isolatedEnvironment(this.root, {
DONUT_E2E_DISABLE_STARTUP_NETWORK: "1",
...(process.env.DONUT_E2E_FIXTURE_URL
@@ -525,6 +575,7 @@ export function appFromEnvironment(name, options = {}) {
extraEnv: options.extraEnv,
args: options.args,
seedVersionCache: options.seedVersionCache,
seedDownloadedBrowser: options.seedDownloadedBrowser,
onboardingCompleted: options.onboardingCompleted,
wayfernTermsAccepted: options.wayfernTermsAccepted,
});
+306
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { existsSync } from "node:fs";
import {
chmod,
@@ -12,6 +13,8 @@ import {
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { crc32 } from "node:zlib";
export const TEST_BROWSER_VERSION = "150.0.7871.100";
@@ -213,6 +216,211 @@ export function extensionZipBase64() {
return "UEsDBBQAAAAAAE8K9Fxo1IfNawAAAGsAAAANAAAAbWFuaWZlc3QuanNvbnsibWFuaWZlc3RfdmVyc2lvbiI6MywibmFtZSI6IkRvbnV0IEUyRSBGaXh0dXJlIiwidmVyc2lvbiI6IjEuMC4wIiwiZGVzY3JpcHRpb24iOiJJc29sYXRlZCB0ZXN0IGV4dGVuc2lvbiJ9UEsBAhQDFAAAAAAATwr0XGjUh81rAAAAawAAAA0AAAAAAAAAAAAAAIABAAAAAG1hbmlmZXN0Lmpzb25QSwUGAAAAAAEAAQA7AAAAlgAAAAAA";
}
// 1980-01-01 00:00, the earliest timestamp the ZIP format can carry. Fixed so
// two calls with the same entries produce byte-identical archives.
const DOS_TIME = 0;
const DOS_DATE = 0x0021;
/**
* Build a ZIP archive from `entries` (`{ name, data }`) with every member
* stored, not deflated.
*
* Stored is what the inline fixture above already is, and it is load-bearing
* for the oversized fixture below: the assertion is about a request body that
* has to stay over the limit under test, so nothing in the archive may shrink
* the padding back under it.
*/
export function buildStoredZip(entries) {
const locals = [];
const central = [];
let offset = 0;
for (const { name, data } of entries) {
const nameBytes = Buffer.from(name, "utf8");
const body = Buffer.isBuffer(data) ? data : Buffer.from(data);
const checksum = crc32(body);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(DOS_TIME, 10);
local.writeUInt16LE(DOS_DATE, 12);
local.writeUInt32LE(checksum, 14);
local.writeUInt32LE(body.length, 18);
local.writeUInt32LE(body.length, 22);
local.writeUInt16LE(nameBytes.length, 26);
locals.push(local, nameBytes, body);
const entry = Buffer.alloc(46);
entry.writeUInt32LE(0x02014b50, 0);
entry.writeUInt16LE(20, 4);
entry.writeUInt16LE(20, 6);
entry.writeUInt16LE(DOS_TIME, 12);
entry.writeUInt16LE(DOS_DATE, 14);
entry.writeUInt32LE(checksum, 16);
entry.writeUInt32LE(body.length, 20);
entry.writeUInt32LE(body.length, 24);
entry.writeUInt16LE(nameBytes.length, 28);
entry.writeUInt32LE(offset, 42);
central.push(entry, nameBytes);
offset += local.length + nameBytes.length + body.length;
}
const directory = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(directory.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...locals, directory, end]);
}
export const OVERSIZED_EXTENSION_NAME = "Donut E2E Oversized Fixture";
/**
* A valid Manifest V3 ZIP padded past the 2 MiB body limit axum applies by
* default, so the raised limit on the extension routes is the only reason a
* request carrying it can succeed.
*
* The padding is random bytes, and the archive stores rather than deflates
* them, so neither the fixture nor the transport can quietly shrink the body
* back under the limit and turn the assertion into a tautology.
*/
export function oversizedExtensionZipBase64(paddingBytes = 3 * 1024 * 1024) {
return buildStoredZip([
{
name: "manifest.json",
data: `${JSON.stringify(
{
manifest_version: 3,
name: OVERSIZED_EXTENSION_NAME,
version: "1.0.0",
description: "Isolated oversized test extension",
},
null,
2,
)}\n`,
},
{ name: "payload.bin", data: randomBytes(paddingBytes) },
]).toString("base64");
}
// What `_locales/<default_locale>/messages.json` resolves the manifest's
// placeholders to. Deliberately free of the `__MSG_` marker so a test can
// assert the stored record carries no placeholder anywhere.
export const LOCALIZED_EXTENSION_MESSAGES = {
extName: "Donut E2E Localized Blocker",
extDescription: "Resolved from the default locale, not the manifest",
extAuthor: "Donut E2E Localization",
};
/**
* A Manifest V3 ZIP shaped the way Chrome Web Store extensions actually ship:
* `name`, `description` and `author` are `__MSG_key__` placeholders and the
* real strings live in `_locales/<default_locale>/messages.json`. uBlock Origin
* Lite is exactly this, which is why an importer that stores the manifest
* verbatim shows users `__MSG_extName__`.
*
* Pass `messages: {}` for a locale file that resolves none of the placeholders,
* or `messages: null` to omit the locale file entirely.
*/
export function localizedExtensionZipBase64({
defaultLocale = "en",
messages = LOCALIZED_EXTENSION_MESSAGES,
} = {}) {
const entries = [
{
name: "manifest.json",
data: `${JSON.stringify(
{
manifest_version: 3,
name: "__MSG_extName__",
version: "2.4.0",
description: "__MSG_extDescription__",
author: "__MSG_extAuthor__",
default_locale: defaultLocale,
},
null,
2,
)}\n`,
},
];
if (messages) {
entries.push({
name: `_locales/${defaultLocale}/messages.json`,
data: `${JSON.stringify(
Object.fromEntries(
Object.entries(messages).map(([key, message]) => [key, { message }]),
),
null,
2,
)}\n`,
});
}
return buildStoredZip(entries).toString("base64");
}
// A 1x1 PNG, inline for the same reason the ZIP above is: no encoder
// dependency, and the exact bytes are what the icon assertions compare.
const EXTENSION_ICON_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
export function extensionIconPngBase64() {
return EXTENSION_ICON_PNG_BASE64;
}
/**
* Write a real unpacked Manifest V3 extension at `directory` and return its
* absolute path.
*
* Unlike the ZIP fixture this one declares `icons` and ships the file they
* point at, so importing the folder exercises icon extraction for both import
* modes: linking reads the icon straight out of the folder, copying reads it
* back out of the ZIP the importer builds. The background service worker is
* what makes a loaded copy observable over CDP, which registers a
* `chrome-extension://<id>/background.js` target.
*/
export async function writeUnpackedExtension(
directory,
{ name = "Donut E2E Unpacked", version = "1.0.0" } = {},
) {
const absolute = path.resolve(directory);
await mkdir(path.join(absolute, "icons"), { recursive: true });
await writeFile(
path.join(absolute, "manifest.json"),
`${JSON.stringify(
{
manifest_version: 3,
name,
version,
description: "Isolated unpacked test extension",
icons: { 16: "icons/icon-16.png", 48: "icons/icon-48.png" },
background: { service_worker: "background.js" },
},
null,
2,
)}\n`,
);
await writeFile(
path.join(absolute, "background.js"),
[
"globalThis.__donutE2eExtension = chrome.runtime.id;",
"chrome.runtime.onInstalled.addListener(() => {",
" console.log('donut e2e extension installed');",
"});",
"",
].join("\n"),
);
const icon = Buffer.from(EXTENSION_ICON_PNG_BASE64, "base64");
for (const size of [16, 48]) {
await writeFile(path.join(absolute, "icons", `icon-${size}.png`), icon);
}
return absolute;
}
export function currentHostOs() {
return os.platform() === "darwin"
? "macos"
@@ -220,3 +428,101 @@ export function currentHostOs() {
? "windows"
: "linux";
}
/**
* Write a Chromium cookie store at schema version 24 with plaintext values.
*
* Plaintext is deliberate: it is what a store looks like when the source
* browser could not reach its keyring, and it lets the suite assert that
* import seals every row with the target profile's key. Chromium reads a row
* whose `encrypted_value` is empty, and drops any row where both columns are
* set, so "value cleared and encrypted_value populated" is the only shape that
* actually loads.
*/
export function writeChromiumCookies(dbPath, cookies) {
const db = new DatabaseSync(dbPath);
db.exec(`
CREATE TABLE cookies(
creation_utc INTEGER NOT NULL,
host_key TEXT NOT NULL,
top_frame_site_key TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
encrypted_value BLOB NOT NULL DEFAULT '',
path TEXT NOT NULL,
expires_utc INTEGER NOT NULL,
is_secure INTEGER NOT NULL,
is_httponly INTEGER NOT NULL,
last_access_utc INTEGER NOT NULL,
has_expires INTEGER NOT NULL DEFAULT 1,
is_persistent INTEGER NOT NULL DEFAULT 1,
priority INTEGER NOT NULL DEFAULT 1,
samesite INTEGER NOT NULL DEFAULT -1,
source_scheme INTEGER NOT NULL DEFAULT 0,
source_port INTEGER NOT NULL DEFAULT -1,
last_update_utc INTEGER NOT NULL DEFAULT 0,
source_type INTEGER NOT NULL DEFAULT 0,
has_cross_site_ancestor INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX cookies_unique_index
ON cookies(host_key, top_frame_site_key, name, path);
CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);
INSERT INTO meta VALUES('version', '24');
INSERT INTO meta VALUES('last_compatible_version', '24');
`);
const insert = db.prepare(
`INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value,
encrypted_value, path, expires_utc, is_secure, is_httponly, last_access_utc)
VALUES(?, ?, '', ?, ?, ?, '/', 0, 0, 0, 0)`,
);
// `encrypted` cookies are written the way Chromium's v23->v24 migration
// does: BindString into a BLOB column, which leaves the storage class as
// TEXT. Reading that as a strict blob returns empty and silently blanks the
// cookie, so the suite has to reproduce it rather than only binding blobs.
const insertAsText = db.prepare(
`INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value,
encrypted_value, path, expires_utc, is_secure, is_httponly, last_access_utc)
VALUES(?, ?, '', ?, '', CAST(? AS TEXT), '/', 0, 0, 0, 0)`,
);
let creation = 13000000000000000;
for (const cookie of cookies) {
if (cookie.encryptedValueText === undefined) {
insert.run(creation++, cookie.host, cookie.name, cookie.value, "");
} else {
insertAsText.run(
creation++,
cookie.host,
cookie.name,
cookie.encryptedValueText,
);
}
}
db.close();
}
/** Write a Chromium History database holding the given URLs. */
export function writeChromiumHistory(dbPath, urls) {
const db = new DatabaseSync(dbPath);
db.exec(`
CREATE TABLE urls(
id INTEGER PRIMARY KEY AUTOINCREMENT,
url LONGVARCHAR,
title LONGVARCHAR,
visit_count INTEGER DEFAULT 0 NOT NULL,
typed_count INTEGER DEFAULT 0 NOT NULL,
last_visit_time INTEGER NOT NULL,
hidden INTEGER DEFAULT 0 NOT NULL
);
CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);
INSERT INTO meta VALUES('version', '69');
INSERT INTO meta VALUES('last_compatible_version', '16');
`);
const insert = db.prepare(
"INSERT INTO urls(url, title, visit_count, typed_count, last_visit_time, hidden) VALUES(?, ?, 1, 0, ?, 0)",
);
let visit = 13000000000000000;
for (const url of urls) {
insert.run(url, url, visit++);
}
db.close();
}
+262 -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";
@@ -11,6 +11,7 @@ import {
defaultWayfernPath,
inspectWayfern,
prepareWayfern,
writeUnpackedExtension,
} from "../lib/fixtures.mjs";
const fixtureUrl = process.env.DONUT_E2E_FIXTURE_URL;
@@ -270,6 +271,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 +352,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",
@@ -629,3 +710,182 @@ test("a proxy worker dies with its browser, with and without the app running", a
await app.close();
}
});
// Two things nothing else covers. First, that an assigned extension group
// actually reaches Wayfern: a loaded MV3 extension registers a
// `chrome-extension://<id>/background.js` service-worker target, so CDP can see
// it from outside. Second, that staging is per profile. It used to be one
// shared `extensions/unpacked` directory wiped on every launch, and because
// Chromium records the absolute staging path and reads those files lazily for
// the life of the process instead of copying them into the profile, launching a
// second profile broke the extension in every browser already running.
test("an assigned extension group reaches Wayfern and each profile stages its own copy", async () => {
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
const localWayfernPath = defaultWayfernPath(
process.env.DONUT_E2E_PROJECT_ROOT,
);
const localWayfernVersion = existsSync(localWayfernPath)
? inspectWayfern(localWayfernPath).version
: null;
const app = appFromEnvironment("browser-extensions", {
seedVersionCache: localWayfernVersion ?? false,
wayfernTermsAccepted: false,
});
const launched = [];
try {
const prepared = await prepareWayfern(
app,
process.env.DONUT_E2E_PROJECT_ROOT,
);
if (!app.session) await app.start();
if (!(await app.invoke("check_wayfern_terms_accepted"))) {
await app.invoke("accept_wayfern_terms");
}
const extension = await app.invoke("add_unpacked_extension", {
name: "Donut Launch Fixture",
path: await writeUnpackedExtension(
path.join(app.root, "fixtures", "loaded-extension"),
{ name: "Donut Launch Fixture", version: "1.0.0" },
),
link: false,
});
const group = await app.invoke("create_extension_group", {
name: "Launch Extensions",
});
await app.invoke("add_extension_to_group", {
groupId: group.id,
extensionId: extension.id,
});
const settings = await app.invoke("get_app_settings");
const saved = await app.invoke("save_app_settings", {
settings: {
...settings,
api_enabled: true,
api_port: 0,
api_token: null,
onboarding_completed: true,
},
});
const base = `http://127.0.0.1:${await app.invoke("start_api_server", { port: 0 })}`;
const stagedManifest = (profileId) =>
path.join(
app.dataRoot,
"data",
"extensions",
"unpacked",
profileId,
extension.id,
"manifest.json",
);
const extensionWorkers = async (debuggingPort) => {
const targets = await fetch(
`http://127.0.0.1:${debuggingPort}/json`,
).then((response) => response.json());
return targets.filter(
(target) =>
target.type === "service_worker" &&
String(target.url).startsWith("chrome-extension://"),
);
};
const launchWithExtension = async (name) => {
const profile = await createRealProfile(app, prepared.version, name);
assert.equal(
(
await app.invoke("assign_extension_group_to_profile", {
profileId: profile.id,
extensionGroupId: group.id,
})
).extension_group_id,
group.id,
);
const run = await request(`${base}/v1/profiles/${profile.id}/run`, {
method: "POST",
token: saved.api_token,
body: { url: `${fixtureUrl}/extension-launch`, headless: true },
});
assert.equal(run.response.status, 200, JSON.stringify(run.value));
const record = {
profile,
debuggingPort: run.value.remote_debugging_port,
};
launched.push(record);
const workers = await app.waitFor(
async () => {
const found = await extensionWorkers(record.debuggingPort);
return found.length > 0 ? found : null;
},
{
timeoutMs: 60_000,
description: `the extension's service worker in ${name}`,
},
);
assert.match(
workers[0].url,
/^chrome-extension:\/\/\w+\/background\.js$/,
);
return record;
};
const first = await launchWithExtension("Extension Launch One");
assert.ok(
existsSync(stagedManifest(first.profile.id)),
"the first profile must stage the extension under its own id",
);
if (process.platform !== "win32") {
// The staged path is what Chromium was handed, and it is per profile.
const running = (await app.invoke("list_browser_profiles")).find(
(item) => item.id === first.profile.id,
);
const command = execFileSync(
"ps",
["-ww", "-o", "command=", "-p", String(running.process_id)],
{ encoding: "utf8" },
);
assert.ok(
command.includes(
`--load-extension=${path.dirname(stagedManifest(first.profile.id))}`,
),
"Wayfern must be pointed at this profile's own staged copy",
);
}
await launchWithExtension("Extension Launch Two");
// The regression itself: the second launch must not have taken the first
// profile's files with it. The staged manifest is what its running browser
// is still reading from.
for (const { profile } of launched) {
assert.ok(
existsSync(stagedManifest(profile.id)),
`${profile.name} lost its staged extension to another profile's launch`,
);
}
for (const { profile } of launched) {
const running = (await app.invoke("list_browser_profiles")).find(
(item) => item.id === profile.id,
);
await app.invoke("kill_browser_profile", { profile: running });
await waitForProcessExit(app, running.process_id);
}
await app.invoke("stop_api_server");
} catch (error) {
await app.capture("failure");
throw error;
} finally {
if (app.session) {
const running = await app.invoke("list_browser_profiles").catch(() => []);
for (const { profile } of launched) {
const record = running.find((item) => item.id === profile.id);
if (record?.process_id && processExists(record.process_id)) {
await app
.invoke("kill_browser_profile", { profile: record })
.catch(() => {});
}
}
}
await app.close();
}
});
+562 -238
View File
@@ -1,9 +1,24 @@
import assert from "node:assert/strict";
import { mkdir, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import {
mkdir,
readdir,
readFile,
realpath,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import test from "node:test";
import { withApp } from "../lib/app.mjs";
import { extensionZipBase64, wireGuardFixture } from "../lib/fixtures.mjs";
import {
extensionIconPngBase64,
extensionZipBase64,
wireGuardFixture,
writeChromiumCookies,
writeChromiumHistory,
writeUnpackedExtension,
} from "../lib/fixtures.mjs";
async function createProfile(app, name = "Entity Profile") {
return app.invoke("create_browser_profile_new", {
@@ -24,257 +39,468 @@ async function createProfile(app, name = "Entity Profile") {
}
test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", async () => {
await withApp("entities-core", async (app) => {
const group = await app.invoke("create_profile_group", {
name: "Research",
});
assert.equal(group.name, "Research");
const renamedGroup = await app.invoke("update_profile_group", {
groupId: group.id,
name: "Research Team",
});
assert.equal(renamedGroup.name, "Research Team");
// Profile import derives its browser version from the downloaded-browsers
// registry, so without an entry every import fails with
// BROWSER_NOT_DOWNLOADED before it touches a single file.
await withApp(
"entities-core",
async (app) => {
const group = await app.invoke("create_profile_group", {
name: "Research",
});
assert.equal(group.name, "Research");
const renamedGroup = await app.invoke("update_profile_group", {
groupId: group.id,
name: "Research Team",
});
assert.equal(renamedGroup.name, "Research Team");
const duplicateError = await app.invokeError("create_profile_group", {
name: "Research Team",
});
assert.match(duplicateError, /GROUP_ALREADY_EXISTS|already exists/i);
const duplicateError = await app.invokeError("create_profile_group", {
name: "Research Team",
});
assert.match(duplicateError, /GROUP_ALREADY_EXISTS|already exists/i);
const proxy = await app.invoke("create_stored_proxy", {
name: "Local Dead Proxy",
proxySettings: {
proxy_type: "http",
host: "127.0.0.1",
port: 9,
username: "e2e-user",
password: "e2e-pass",
},
});
assert.equal(proxy.proxy_settings.password, "e2e-pass");
const updatedProxy = await app.invoke("update_stored_proxy", {
proxyId: proxy.id,
name: "Updated Proxy",
proxySettings: {
proxy_type: "socks5",
host: "127.0.0.1",
port: 9,
username: null,
password: null,
},
});
assert.equal(updatedProxy.name, "Updated Proxy");
assert.equal(updatedProxy.updated_at >= proxy.updated_at, true);
const proxy = await app.invoke("create_stored_proxy", {
name: "Local Dead Proxy",
proxySettings: {
proxy_type: "http",
host: "127.0.0.1",
port: 9,
username: "e2e-user",
password: "e2e-pass",
},
});
assert.equal(proxy.proxy_settings.password, "e2e-pass");
const updatedProxy = await app.invoke("update_stored_proxy", {
proxyId: proxy.id,
name: "Updated Proxy",
proxySettings: {
proxy_type: "socks5",
host: "127.0.0.1",
port: 9,
username: null,
password: null,
},
});
assert.equal(updatedProxy.name, "Updated Proxy");
assert.equal(updatedProxy.updated_at >= proxy.updated_at, true);
const parsed = await app.invoke("parse_txt_proxies", {
content: [
"http://one.example:8080",
"two.example:1080:user:pass",
"not a proxy",
].join("\n"),
});
assert.equal(parsed.length, 3);
assert.ok(parsed.some((result) => result.status === "parsed"));
assert.ok(parsed.some((result) => result.status === "invalid"));
const parsedProxy = parsed.find((result) => result.status === "parsed");
const { status: _status, ...parsedProxyFields } = parsedProxy;
const parsedImport = await app.invoke("import_proxies_from_parsed", {
parsedProxies: [parsedProxyFields],
namePrefix: "Parsed",
});
assert.equal(parsedImport.imported_count, 1);
const parsed = await app.invoke("parse_txt_proxies", {
content: [
"http://one.example:8080",
"two.example:1080:user:pass",
"not a proxy",
].join("\n"),
});
assert.equal(parsed.length, 3);
assert.ok(parsed.some((result) => result.status === "parsed"));
assert.ok(parsed.some((result) => result.status === "invalid"));
const parsedProxy = parsed.find((result) => result.status === "parsed");
const { status: _status, ...parsedProxyFields } = parsedProxy;
const parsedImport = await app.invoke("import_proxies_from_parsed", {
parsedProxies: [parsedProxyFields],
namePrefix: "Parsed",
});
assert.equal(parsedImport.imported_count, 1);
const validityError = await app.invokeError("check_proxy_validity", {
proxyId: proxy.id,
proxySettings: null,
});
assert.match(validityError, /Proxy check failed|Could not connect/i);
const cachedValidity = await app.invoke("get_cached_proxy_check", {
proxyId: proxy.id,
});
assert.ok(cachedValidity === null || cachedValidity.is_valid === false);
const validityError = await app.invokeError("check_proxy_validity", {
proxyId: proxy.id,
proxySettings: null,
});
assert.match(validityError, /Proxy check failed|Could not connect/i);
const cachedValidity = await app.invoke("get_cached_proxy_check", {
proxyId: proxy.id,
});
assert.ok(cachedValidity === null || cachedValidity.is_valid === false);
// Donut accepts one VLESS shape (REALITY + XTLS Vision over TCP). The form
// uses this to tell the user WHICH part of their setup is unsupported
// instead of implying they mistyped, so the reason must survive the IPC hop.
const goodVless =
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@example.com:443" +
"?security=reality&flow=xtls-rprx-vision&encryption=none&type=tcp" +
"&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&sid=00&fp=chrome";
assert.equal(
await app.invoke("validate_vless_uri", { uri: goodVless }),
null,
);
for (const [uri, reason] of [
[goodVless.replace("security=reality", "security=tls"), "security"],
[goodVless.replace("type=tcp", "type=ws"), "transport"],
[goodVless.replace("flow=xtls-rprx-vision", "flow=none"), "flow"],
]) {
// invokeError returns the command's error wrapped in a message, so match
// rather than JSON.parse the whole string.
const error = await app.invokeError("validate_vless_uri", { uri });
assert.match(error, /VLESS_CONFIG_INVALID/);
assert.match(
error,
new RegExp(`"reason":"${reason}"`),
`expected reason ${reason} for ${uri}, got: ${error}`,
// Donut accepts one VLESS shape (REALITY + XTLS Vision over TCP). The form
// uses this to tell the user WHICH part of their setup is unsupported
// instead of implying they mistyped, so the reason must survive the IPC hop.
const goodVless =
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@example.com:443" +
"?security=reality&flow=xtls-rprx-vision&encryption=none&type=tcp" +
"&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&sid=00&fp=chrome";
assert.equal(
await app.invoke("validate_vless_uri", { uri: goodVless }),
null,
);
}
const exported = JSON.parse(
await app.invoke("export_proxies", { format: "json" }),
);
assert.equal(exported.proxies.length, 2);
assert.ok(exported.proxies.some((item) => item.name === "Updated Proxy"));
assert.ok(exported.proxies.some((item) => item.name === "Parsed Proxy 1"));
const importResult = await app.invoke("import_proxies_json", {
content: JSON.stringify({
version: "1",
source: "Donut Browser",
exported_at: new Date().toISOString(),
proxies: [
for (const [uri, reason] of [
[goodVless.replace("security=reality", "security=tls"), "security"],
[goodVless.replace("type=tcp", "type=ws"), "transport"],
[goodVless.replace("flow=xtls-rprx-vision", "flow=none"), "flow"],
]) {
// invokeError returns the command's error wrapped in a message, so match
// rather than JSON.parse the whole string.
const error = await app.invokeError("validate_vless_uri", { uri });
assert.match(error, /VLESS_CONFIG_INVALID/);
assert.match(
error,
new RegExp(`"reason":"${reason}"`),
`expected reason ${reason} for ${uri}, got: ${error}`,
);
}
const exported = JSON.parse(
await app.invoke("export_proxies", { format: "json" }),
);
assert.equal(exported.proxies.length, 2);
assert.ok(exported.proxies.some((item) => item.name === "Updated Proxy"));
assert.ok(
exported.proxies.some((item) => item.name === "Parsed Proxy 1"),
);
const importResult = await app.invoke("import_proxies_json", {
content: JSON.stringify({
version: "1",
source: "Donut Browser",
exported_at: new Date().toISOString(),
proxies: [
{
name: "Imported Proxy",
type: "http",
host: "127.0.0.1",
port: 8081,
},
],
}),
});
assert.equal(importResult.imported_count, 1);
const profile = await createProfile(app);
assert.equal(profile.name, "Entity Profile");
assert.equal(
(
await app.invoke("update_profile_proxy", {
profileId: profile.id,
proxyId: proxy.id,
})
).proxy_id,
proxy.id,
);
await app.invoke("assign_profiles_to_group", {
profileIds: [profile.id],
groupId: group.id,
});
await app.invoke("rename_profile", {
profileId: profile.id,
newName: "Renamed Profile",
});
await app.invoke("update_profile_tags", {
profileId: profile.id,
tags: ["alpha", "automation"],
});
await app.invoke("update_profile_note", {
profileId: profile.id,
note: "Extensive E2E metadata",
});
await app.invoke("update_profile_window_color", {
profileId: profile.id,
windowColor: "#123456",
});
await app.invoke("update_profile_launch_hook", {
profileId: profile.id,
launchHook: `${process.env.DONUT_E2E_FIXTURE_URL}/launch-hook`,
});
const invalidHook = await app.invokeError("update_profile_launch_hook", {
profileId: profile.id,
launchHook: "file:///etc/passwd",
});
assert.match(invalidHook, /INVALID_LAUNCH_HOOK_URL/);
await app.invoke("update_profile_proxy_bypass_rules", {
profileId: profile.id,
rules: ["localhost", "*.internal.example"],
});
await app.invoke("update_profile_dns_blocklist", {
profileId: profile.id,
dnsBlocklist: "light",
});
await app.invoke("update_profile_clear_on_close", {
profileId: profile.id,
clearOnClose: true,
});
const profiles = await app.invoke("list_browser_profiles");
const changed = profiles.find((item) => item.id === profile.id);
assert.deepEqual(changed.tags, ["alpha", "automation"]);
assert.equal(changed.note, "Extensive E2E metadata");
assert.equal(changed.window_color, "#123456");
assert.equal(changed.group_id, group.id);
assert.deepEqual(changed.proxy_bypass_rules, [
"localhost",
"*.internal.example",
]);
assert.equal(changed.dns_blocklist, "light");
assert.equal(changed.clear_on_close, true);
assert.deepEqual((await app.invoke("get_all_tags")).sort(), [
"alpha",
"automation",
]);
assert.ok(Array.isArray(await app.invoke("detect_existing_profiles")));
const importRoot = path.join(app.root, "profile-import-fixture");
const importProfile = path.join(importRoot, "Default");
await mkdir(importProfile, { recursive: true });
await writeFile(
path.join(importProfile, "Preferences"),
JSON.stringify({
profile: { name: "Imported fixture", exit_type: "Crashed" },
download: { default_directory: "/Users/someone-else/Downloads" },
}),
);
// A Secure Preferences with MACs that can never validate under Wayfern,
// one real (relative-path) extension and one component extension that
// belongs to the source browser's bundle.
await writeFile(
path.join(importProfile, "Secure Preferences"),
JSON.stringify({
protection: { super_mac: "deadbeef", macs: { extensions: {} } },
extensions: {
settings: {
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: {
path: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/1.0_0",
},
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb: {
path: "/Applications/Chromium.app/Contents/Resources/component",
},
},
},
}),
);
// Caches must not be copied, and site data must be.
await mkdir(path.join(importProfile, "Cache"), { recursive: true });
await writeFile(path.join(importProfile, "Cache", "data_0"), "junk");
await mkdir(path.join(importProfile, "Local Storage", "leveldb"), {
recursive: true,
});
await writeFile(
path.join(importProfile, "Local Storage", "leveldb", "000003.log"),
"site-data",
);
writeChromiumHistory(path.join(importProfile, "History"), [
"https://example.com/",
"https://example.org/",
]);
writeChromiumCookies(path.join(importProfile, "Cookies"), [
{ host: "example.com", name: "sid", value: "session-token" },
{ host: "example.org", name: "pref", value: "dark" },
// Sealed with a key this machine does not have, and stored the way
// Chromium's own v23->v24 migration stores it (TEXT in a BLOB column).
// It must be reported as unrecoverable, never silently blanked and
// counted as migrated.
{
host: "sealed.example",
name: "sid",
encryptedValueText: "v10\u0001\u0002\u0003unopenable-ciphertext",
},
]);
const scanned = await app.invoke("scan_folder_for_profiles", {
folderPath: importRoot,
});
assert.equal(scanned.length, 1);
assert.equal(scanned[0].mapped_browser, "wayfern");
const importBatch = await app.invoke("import_browser_profiles", {
items: [
{
name: "Imported Proxy",
type: "http",
host: "127.0.0.1",
port: 8081,
source_path: scanned[0].path,
browser_type: scanned[0].browser,
new_profile_name: "Imported Profile",
proxy_id: null,
vpn_id: null,
},
],
}),
});
assert.equal(importResult.imported_count, 1);
groupId: null,
duplicateStrategy: "rename",
// A stored fingerprint, as elsewhere in this suite: generating a real
// one shells out to the Wayfern binary, which no CRUD suite installs.
wayfernConfig: { fingerprint: "{}" },
});
assert.equal(
importBatch.imported_count,
1,
`import must succeed: ${JSON.stringify(importBatch.results)}`,
);
const profile = await createProfile(app);
assert.equal(profile.name, "Entity Profile");
assert.equal(
(
await app.invoke("update_profile_proxy", {
profileId: profile.id,
proxyId: proxy.id,
})
).proxy_id,
proxy.id,
);
await app.invoke("assign_profiles_to_group", {
profileIds: [profile.id],
groupId: group.id,
});
await app.invoke("rename_profile", {
profileId: profile.id,
newName: "Renamed Profile",
});
await app.invoke("update_profile_tags", {
profileId: profile.id,
tags: ["alpha", "automation"],
});
await app.invoke("update_profile_note", {
profileId: profile.id,
note: "Extensive E2E metadata",
});
await app.invoke("update_profile_window_color", {
profileId: profile.id,
windowColor: "#123456",
});
await app.invoke("update_profile_launch_hook", {
profileId: profile.id,
launchHook: `${process.env.DONUT_E2E_FIXTURE_URL}/launch-hook`,
});
const invalidHook = await app.invokeError("update_profile_launch_hook", {
profileId: profile.id,
launchHook: "file:///etc/passwd",
});
assert.match(invalidHook, /INVALID_LAUNCH_HOOK_URL/);
await app.invoke("update_profile_proxy_bypass_rules", {
profileId: profile.id,
rules: ["localhost", "*.internal.example"],
});
await app.invoke("update_profile_dns_blocklist", {
profileId: profile.id,
dnsBlocklist: "light",
});
await app.invoke("update_profile_clear_on_close", {
profileId: profile.id,
clearOnClose: true,
});
const imported = importBatch.results[0];
// The assertion whose absence let the layout bug ship: an import that
// carries nothing used to be indistinguishable from a successful one.
assert.ok(
imported.report,
"an imported profile must report what it carried",
);
assert.equal(imported.report.cookies_migrated, 2);
assert.equal(
imported.report.cookies_unrecoverable,
1,
"a cookie no key can open must be counted, not silently emptied",
);
assert.equal(imported.report.history_entries, 2);
assert.equal(imported.report.extensions_migrated, 1);
assert.ok(imported.report.local_storage_origins > 0);
const profiles = await app.invoke("list_browser_profiles");
const changed = profiles.find((item) => item.id === profile.id);
assert.deepEqual(changed.tags, ["alpha", "automation"]);
assert.equal(changed.note, "Extensive E2E metadata");
assert.equal(changed.window_color, "#123456");
assert.equal(changed.group_id, group.id);
assert.deepEqual(changed.proxy_bypass_rules, [
"localhost",
"*.internal.example",
]);
assert.equal(changed.dns_blocklist, "light");
assert.equal(changed.clear_on_close, true);
assert.deepEqual((await app.invoke("get_all_tags")).sort(), [
"alpha",
"automation",
]);
const importedDir = path.join(
app.dataRoot,
"data",
"profiles",
imported.profile_id,
"profile",
);
// Chromium reads <user-data-dir>/Default/, so anything at the root is
// invisible to the browser no matter how faithfully it was copied.
assert.ok(
existsSync(path.join(importedDir, "Default", "Preferences")),
"profile content must land under Default/",
);
assert.ok(
!existsSync(path.join(importedDir, "Preferences")),
"nothing profile-scoped may sit at the user-data-dir root",
);
assert.ok(
existsSync(path.join(importedDir, "os_crypt_key")),
"Wayfern reads its key from the user-data-dir root",
);
assert.ok(
!existsSync(path.join(importedDir, "Default", "Cache")),
"caches are pure waste and must not be copied",
);
assert.ok(
existsSync(
path.join(
importedDir,
"Default",
"Local Storage",
"leveldb",
"000003.log",
),
),
"site data must survive",
);
assert.ok(Array.isArray(await app.invoke("detect_existing_profiles")));
const importRoot = path.join(app.root, "profile-import-fixture");
const importProfile = path.join(importRoot, "Default");
await mkdir(importProfile, { recursive: true });
await writeFile(
path.join(importProfile, "Preferences"),
JSON.stringify({ profile: { name: "Imported fixture" } }),
);
const scanned = await app.invoke("scan_folder_for_profiles", {
folderPath: importRoot,
});
assert.equal(scanned.length, 1);
assert.equal(scanned[0].mapped_browser, "wayfern");
const importBatch = await app.invoke("import_browser_profiles", {
items: [
{
source_path: scanned[0].path,
browser_type: scanned[0].browser,
new_profile_name: "Imported Profile",
proxy_id: null,
vpn_id: null,
},
],
groupId: null,
duplicateStrategy: "rename",
wayfernConfig: null,
});
assert.equal(importBatch.imported_count + importBatch.failed_count, 1);
const archivePath = path.join(app.root, "profile-import-fixture.zip");
await writeFile(archivePath, Buffer.from(extensionZipBase64(), "base64"));
const archiveScan = await app.invoke("scan_profile_archive", {
archivePath,
});
assert.ok(Array.isArray(archiveScan.profiles));
await app.invoke("cleanup_profile_import_scratch", {
extractedDir: archiveScan.extracted_dir,
});
const importedCookies = path.join(
importedDir,
"Default",
process.platform === "win32"
? path.join("Network", "Cookies")
: "Cookies",
);
assert.ok(
existsSync(importedCookies),
"cookies must sit where this platform's Chromium reads them",
);
// Chromium drops any row where both value and encrypted_value are set, so
// a "migrated" cookie that kept its plaintext would never load.
const cookieDb = new DatabaseSync(importedCookies, { readOnly: true });
const rows = cookieDb
.prepare(
"SELECT host_key, value, length(encrypted_value) AS enc FROM cookies ORDER BY host_key",
)
.all();
cookieDb.close();
assert.equal(
rows.length,
2,
"the unrecoverable row is dropped, not kept empty",
);
for (const row of rows) {
assert.equal(row.value, "", `${row.host_key} kept a plaintext value`);
assert.ok(row.enc > 0, `${row.host_key} was not re-encrypted`);
}
const clone = await app.invoke("clone_profile", {
profileId: profile.id,
name: "Cloned Profile",
});
assert.notEqual(clone.id, profile.id);
assert.equal(clone.name, "Cloned Profile");
const counts = await app.invoke("get_groups_with_profile_counts");
assert.equal(counts.find((item) => item.id === group.id).count, 2);
assert.equal((await app.invoke("get_profile_groups")).length, 1);
const securePrefs = JSON.parse(
await readFile(
path.join(importedDir, "Default", "Secure Preferences"),
"utf8",
),
);
assert.equal(
securePrefs.protection,
undefined,
"MACs from another machine can never validate and must be stripped",
);
assert.ok(
securePrefs.extensions.settings.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,
"the user's own extension must survive",
);
assert.equal(
securePrefs.extensions.settings.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,
undefined,
"a component extension pointing into the source browser must be dropped",
);
await app.invoke("delete_selected_profiles", {
profileIds: [profile.id, clone.id],
});
assert.deepEqual(await app.invoke("list_browser_profiles"), []);
await app.invoke("delete_profile_group", { groupId: group.id });
await app.invoke("delete_stored_proxy", { proxyId: proxy.id });
for (const importedProxy of (await app.invoke("get_stored_proxies")).filter(
(item) =>
item.name === "Imported Proxy" || item.name.startsWith("Parsed Proxy"),
)) {
await app.invoke("delete_stored_proxy", { proxyId: importedProxy.id });
}
});
const prefs = JSON.parse(
await readFile(
path.join(importedDir, "Default", "Preferences"),
"utf8",
),
);
assert.equal(prefs.profile.exit_type, "Normal");
assert.equal(prefs.download.default_directory, undefined);
assert.equal(prefs.profile.name, "Imported fixture");
// A Gecko profile must say why it cannot be imported instead of silently
// producing an empty one.
const firefoxRoot = path.join(app.root, "firefox-profile-fixture");
await mkdir(firefoxRoot, { recursive: true });
await writeFile(path.join(firefoxRoot, "prefs.js"), "// prefs");
await writeFile(path.join(firefoxRoot, "places.sqlite"), "");
const geckoBatch = await app.invoke("import_browser_profiles", {
items: [
{
source_path: firefoxRoot,
browser_type: "firefox",
new_profile_name: "Gecko Profile",
proxy_id: null,
vpn_id: null,
},
],
groupId: null,
duplicateStrategy: "rename",
wayfernConfig: { fingerprint: "{}" },
});
assert.equal(geckoBatch.failed_count, 1);
assert.match(
geckoBatch.results[0].error,
/IMPORT_SOURCE_NOT_CHROMIUM/,
"a Firefox folder must be rejected by name, not imported empty",
);
const archivePath = path.join(app.root, "profile-import-fixture.zip");
await writeFile(archivePath, Buffer.from(extensionZipBase64(), "base64"));
const archiveScan = await app.invoke("scan_profile_archive", {
archivePath,
});
assert.ok(Array.isArray(archiveScan.profiles));
await app.invoke("cleanup_profile_import_scratch", {
extractedDir: archiveScan.extracted_dir,
});
const clone = await app.invoke("clone_profile", {
profileId: profile.id,
name: "Cloned Profile",
});
assert.notEqual(clone.id, profile.id);
assert.equal(clone.name, "Cloned Profile");
const counts = await app.invoke("get_groups_with_profile_counts");
assert.equal(counts.find((item) => item.id === group.id).count, 2);
assert.equal((await app.invoke("get_profile_groups")).length, 1);
await app.invoke("delete_selected_profiles", {
profileIds: [profile.id, clone.id, imported.profile_id],
});
assert.deepEqual(await app.invoke("list_browser_profiles"), []);
await app.invoke("delete_profile_group", { groupId: group.id });
await app.invoke("delete_stored_proxy", { proxyId: proxy.id });
for (const importedProxy of (
await app.invoke("get_stored_proxies")
).filter(
(item) =>
item.name === "Imported Proxy" ||
item.name.startsWith("Parsed Proxy"),
)) {
await app.invoke("delete_stored_proxy", { proxyId: importedProxy.id });
}
},
{ seedDownloadedBrowser: true },
);
});
test("extensions, extension groups, VPN storage, DNS rules, and event-backed assignments", async () => {
@@ -337,6 +563,104 @@ test("extensions, extension groups, VPN storage, DNS rules, and event-backed ass
await app.invoke("delete_extension_group", { groupId: extensionGroup.id });
await app.invoke("delete_extension", { extensionId: extension.id });
// Folder imports, the "Load unpacked" flow. Copying packs the folder into
// the store; linking loads it from where the user keeps it, which only
// exists on this machine and therefore never syncs.
const unpackedDir = await writeUnpackedExtension(
path.join(app.root, "fixtures", "unpacked-extension"),
);
const copied = await app.invoke("add_unpacked_extension", {
name: "Overridden By The Manifest",
path: unpackedDir,
link: false,
});
assert.equal(copied.source_kind, "unpacked");
assert.equal(copied.linked_path, null);
assert.equal(copied.file_type, "zip");
assert.equal(copied.file_name, "unpacked-extension.zip");
assert.equal(copied.name, "Donut E2E Unpacked");
assert.equal(copied.version, "1.0.0");
// The folder declares icons, so packing it must carry one through into the
// store rather than dropping it the way the icon-less ZIP fixture does.
assert.equal(
await app.invoke("get_extension_icon", { extensionId: copied.id }),
`data:image/png;base64,${extensionIconPngBase64()}`,
);
const linked = await app.invoke("add_unpacked_extension", {
name: "Linked Fixture",
path: unpackedDir,
link: true,
});
assert.equal(linked.source_kind, "unpacked");
assert.equal(linked.file_type, "unpacked");
assert.equal(linked.linked_path, await realpath(unpackedDir));
assert.equal(
linked.sync_enabled,
false,
"a linked extension has no payload to upload, so it must never be synced",
);
const repackedDir = await writeUnpackedExtension(
path.join(app.root, "fixtures", "unpacked-extension-v2"),
{ name: "Donut E2E Unpacked v2", version: "2.0.0" },
);
const repacked = await app.invoke("update_extension_from_path", {
extensionId: copied.id,
name: "Repacked Fixture Extension",
path: repackedDir,
link: false,
});
assert.equal(repacked.name, "Repacked Fixture Extension");
assert.equal(repacked.version, "2.0.0");
assert.equal(repacked.file_name, "unpacked-extension-v2.zip");
assert.deepEqual(
await readdir(
path.join(app.dataRoot, "data", "extensions", copied.id, "file"),
),
["unpacked-extension-v2.zip"],
"re-importing replaces the stored payload instead of stacking a second one",
);
// Re-importing a linked extension as a copy ends the link, which is what
// makes it portable again. With no explicit name the manifest names it.
const unlinked = await app.invoke("update_extension_from_path", {
extensionId: linked.id,
name: null,
path: repackedDir,
link: false,
});
assert.equal(unlinked.linked_path, null);
assert.equal(unlinked.source_kind, "unpacked");
assert.equal(unlinked.name, "Donut E2E Unpacked v2");
assert.match(
await app.invokeError("add_unpacked_extension", {
name: "Not An Extension",
path: app.root,
link: false,
}),
/EXTENSION_MANIFEST_MISSING/,
);
assert.match(
await app.invokeError("update_extension_from_path", {
extensionId: unlinked.id,
name: null,
path: path.join(app.root, "fixtures", "absent"),
link: false,
}),
/EXTENSION_DIR_NOT_FOUND/,
);
for (const id of [copied.id, unlinked.id]) {
await app.invoke("delete_extension", { extensionId: id });
}
assert.deepEqual(await app.invoke("list_extensions"), []);
assert.ok(
existsSync(path.join(unpackedDir, "manifest.json")),
"importing a folder must never move or consume the user's copy of it",
);
const vpn = await app.invoke("create_vpn_config_manual", {
name: "E2E WireGuard",
vpnType: "WireGuard",
+553
View File
@@ -3,6 +3,14 @@ import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import { withApp } from "../lib/app.mjs";
import {
extensionZipBase64,
LOCALIZED_EXTENSION_MESSAGES,
localizedExtensionZipBase64,
OVERSIZED_EXTENSION_NAME,
oversizedExtensionZipBase64,
writeUnpackedExtension,
} from "../lib/fixtures.mjs";
const VLESS_URI =
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@127.0.0.1:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.example.com&fp=chrome&pbk=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc&sid=0123456789abcdef&spx=%2F&type=tcp&headerType=none#MCP";
@@ -93,6 +101,27 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec
]) {
assert.ok(paths.includes(required), `OpenAPI is missing ${required}`);
}
// The served spec comes from the hand-maintained ApiDoc derive, not from
// the router, so an extension route can answer requests while being absent
// from the contract an agent generates its client from.
for (const [route, methods] of [
["/v1/extensions", ["get", "post"]],
["/v1/extensions/{id}", ["get", "put", "delete"]],
["/v1/extension-groups", ["get", "post"]],
["/v1/extension-groups/{id}", ["get", "put", "delete"]],
[
"/v1/extension-groups/{id}/extensions/{extension_id}",
["post", "delete"],
],
]) {
assert.ok(paths.includes(route), `OpenAPI is missing ${route}`);
for (const method of methods) {
assert.ok(
openapi.value.paths[route][method],
`OpenAPI is missing ${method.toUpperCase()} ${route}`,
);
}
}
const unauthorized = await jsonRequest(`${base}/v1/profiles`);
assert.equal(unauthorized.response.status, 401);
@@ -197,6 +226,428 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec
assert.equal(imported.response.status, 200);
assert.equal(imported.value.imported_count, 1);
// Extensions arrive either as an inline payload or as a path the app can
// read, and the folder form is the whole point: it is how an agent reaches
// the "load unpacked" flow that the desktop offers through a file picker.
const archiveExtension = await jsonRequest(`${base}/v1/extensions`, {
method: "POST",
token: saved.api_token,
body: {
name: "REST Archive Extension",
file_name: "fixture.zip",
file_data_base64: extensionZipBase64(),
},
});
assert.equal(
archiveExtension.response.status,
201,
JSON.stringify(archiveExtension.value),
);
assert.equal(archiveExtension.value.name, "Donut E2E Fixture");
assert.equal(archiveExtension.value.source_kind, "archive");
assert.equal(archiveExtension.value.linked_path, null);
const unpackedDir = await writeUnpackedExtension(
path.join(app.root, "fixtures", "rest-unpacked-extension"),
{ name: "Donut REST Unpacked", version: "1.2.0" },
);
const folderExtension = await jsonRequest(`${base}/v1/extensions`, {
method: "POST",
token: saved.api_token,
body: { name: "REST Folder Extension", source_path: unpackedDir },
});
assert.equal(
folderExtension.response.status,
201,
JSON.stringify(folderExtension.value),
);
assert.equal(folderExtension.value.name, "Donut REST Unpacked");
assert.equal(folderExtension.value.version, "1.2.0");
assert.equal(folderExtension.value.source_kind, "unpacked");
assert.equal(folderExtension.value.linked_path, null);
// Two sources in one request have no defined winner, so the request is
// refused rather than silently resolved.
const ambiguousSource = await jsonRequest(`${base}/v1/extensions`, {
method: "POST",
token: saved.api_token,
body: {
name: "REST Ambiguous Extension",
file_name: "fixture.zip",
file_data_base64: extensionZipBase64(),
source_path: unpackedDir,
},
});
assert.equal(ambiguousSource.response.status, 400);
assert.match(
JSON.stringify(ambiguousSource.value),
/EXTENSION_SOURCE_REQUIRED/,
);
const sourcelessExtension = await jsonRequest(`${base}/v1/extensions`, {
method: "POST",
token: saved.api_token,
body: { name: "REST Sourceless Extension" },
});
assert.equal(sourcelessExtension.response.status, 400);
assert.match(
JSON.stringify(sourcelessExtension.value),
/EXTENSION_SOURCE_REQUIRED/,
);
// An archive has no folder to keep loading from, so linking one is refused
// rather than quietly stored as a copy.
const linkedArchive = await jsonRequest(`${base}/v1/extensions`, {
method: "POST",
token: saved.api_token,
body: {
name: "REST Linked Archive",
file_name: "fixture.zip",
file_data_base64: extensionZipBase64(),
link: true,
},
});
assert.equal(linkedArchive.response.status, 400);
assert.match(
JSON.stringify(linkedArchive.value),
/EXTENSION_LINK_REQUIRES_DIRECTORY/,
);
const extensionId = folderExtension.value.id;
assert.equal(
(
await jsonRequest(`${base}/v1/extensions/${extensionId}`, {
token: saved.api_token,
})
).value.id,
extensionId,
);
const renamedExtension = await jsonRequest(
`${base}/v1/extensions/${extensionId}`,
{
method: "PUT",
token: saved.api_token,
body: { name: "REST Renamed Extension" },
},
);
assert.equal(
renamedExtension.response.status,
200,
JSON.stringify(renamedExtension.value),
);
assert.equal(renamedExtension.value.name, "REST Renamed Extension");
assert.equal(
(await jsonRequest(`${base}/v1/extensions`, { token: saved.api_token }))
.value.length,
2,
);
// Axum's default body limit is 2 MiB, which plenty of real `.crx` files
// exceed: every one of them was refused before the handler ran until the
// extension payload routes got a limit of their own. The fixture below is
// stored rather than deflated, so the body genuinely stays over the
// default and a 201 can only come from the raised limit.
const oversizedBody = {
name: "REST Oversized Extension",
file_name: "oversized.zip",
file_data_base64: oversizedExtensionZipBase64(),
};
assert.ok(
Buffer.byteLength(JSON.stringify(oversizedBody)) > 2 * 1024 * 1024,
"the oversized fixture must exceed the default body limit it tests",
);
const oversized = await jsonRequest(`${base}/v1/extensions`, {
method: "POST",
token: saved.api_token,
body: oversizedBody,
});
assert.equal(
oversized.response.status,
201,
JSON.stringify(oversized.value),
);
// Read out of the archive that arrived, so the payload landed whole rather
// than merely being accepted.
assert.equal(oversized.value.name, OVERSIZED_EXTENSION_NAME);
assert.equal(oversized.value.version, "1.0.0");
assert.equal(oversized.value.file_type, "zip");
// The raised limit is scoped to the two paths that carry a payload. A
// group name is never megabytes long, so a route that accepted one would
// mean the layer had been attached to the whole router.
const oversizedGroupBody = { name: "G".repeat(3 * 1024 * 1024) };
assert.ok(
Buffer.byteLength(JSON.stringify(oversizedGroupBody)) > 2 * 1024 * 1024,
);
const oversizedGroup = await jsonRequest(`${base}/v1/extension-groups`, {
method: "POST",
token: saved.api_token,
body: oversizedGroupBody,
});
assert.equal(
oversizedGroup.response.status,
413,
JSON.stringify(oversizedGroup.value),
);
assert.deepEqual(
(
await jsonRequest(`${base}/v1/extension-groups`, {
token: saved.api_token,
})
).value,
[],
"the refused group request must not have stored anything",
);
// Chrome Web Store extensions overwhelmingly localize their manifest: the
// name a user recognizes sits in `_locales/<default_locale>/messages.json`
// and the manifest holds `__MSG_extName__`. Storing the manifest verbatim
// is what puts a raw placeholder in the extension list.
const localized = await jsonRequest(`${base}/v1/extensions`, {
method: "POST",
token: saved.api_token,
body: {
name: "REST Localized Extension",
file_name: "localized.zip",
file_data_base64: localizedExtensionZipBase64(),
},
});
assert.equal(
localized.response.status,
201,
JSON.stringify(localized.value),
);
assert.equal(localized.value.name, LOCALIZED_EXTENSION_MESSAGES.extName);
assert.equal(
localized.value.description,
LOCALIZED_EXTENSION_MESSAGES.extDescription,
);
assert.equal(
localized.value.author,
LOCALIZED_EXTENSION_MESSAGES.extAuthor,
);
assert.doesNotMatch(JSON.stringify(localized.value), /__MSG_/);
// The resolved strings have to be what was persisted, not something the
// create response computed on its way out.
assert.equal(
(
await jsonRequest(`${base}/v1/extensions/${localized.value.id}`, {
token: saved.api_token,
})
).value.name,
LOCALIZED_EXTENSION_MESSAGES.extName,
);
// A placeholder the locale file cannot resolve falls back to the name the
// caller sent. What it must never do is store `__MSG_extName__` itself.
const unresolved = await jsonRequest(`${base}/v1/extensions`, {
method: "POST",
token: saved.api_token,
body: {
name: "REST Unresolved Placeholder",
file_name: "unresolved.zip",
file_data_base64: localizedExtensionZipBase64({ messages: {} }),
},
});
assert.equal(
unresolved.response.status,
201,
JSON.stringify(unresolved.value),
);
assert.equal(unresolved.value.name, "REST Unresolved Placeholder");
assert.equal(unresolved.value.description, null);
assert.equal(unresolved.value.author, null);
assert.doesNotMatch(JSON.stringify(unresolved.value), /__MSG_/);
const extensionGroup = await jsonRequest(`${base}/v1/extension-groups`, {
method: "POST",
token: saved.api_token,
body: { name: "REST Extension Group" },
});
assert.equal(
extensionGroup.response.status,
201,
JSON.stringify(extensionGroup.value),
);
assert.equal(extensionGroup.value.name, "REST Extension Group");
assert.deepEqual(extensionGroup.value.extension_ids, []);
const extensionGroupId = extensionGroup.value.id;
const renamedExtensionGroup = await jsonRequest(
`${base}/v1/extension-groups/${extensionGroupId}`,
{
method: "PUT",
token: saved.api_token,
body: { name: "REST Extension Group Updated" },
},
);
assert.equal(
renamedExtensionGroup.response.status,
200,
JSON.stringify(renamedExtensionGroup.value),
);
assert.equal(
renamedExtensionGroup.value.name,
"REST Extension Group Updated",
);
const membershipUrl = `${base}/v1/extension-groups/${extensionGroupId}/extensions/${extensionId}`;
const joined = await jsonRequest(membershipUrl, {
method: "POST",
token: saved.api_token,
});
assert.equal(joined.response.status, 200, JSON.stringify(joined.value));
assert.deepEqual(joined.value.extension_ids, [extensionId]);
assert.deepEqual(
(
await jsonRequest(`${base}/v1/extension-groups/${extensionGroupId}`, {
token: saved.api_token,
})
).value.extension_ids,
[extensionId],
);
const left = await jsonRequest(membershipUrl, {
method: "DELETE",
token: saved.api_token,
});
assert.equal(left.response.status, 200, JSON.stringify(left.value));
assert.deepEqual(left.value.extension_ids, []);
assert.deepEqual(
(
await jsonRequest(`${base}/v1/extension-groups/${extensionGroupId}`, {
token: saved.api_token,
})
).value.extension_ids,
[],
);
// The whole path an automation client takes: an extension, a group holding
// it, and a profile that will load that group the next time it launches.
// Each piece already had coverage; the sequence did not, and it is the
// sequence that has to work for extensions to be usable over REST at all.
const launchProfile = await app.invoke("create_browser_profile_new", {
name: "REST Extension Profile",
browserStr: "wayfern",
version: "150.0.7871.100",
releaseType: "stable",
proxyId: null,
vpnId: null,
// A stored fingerprint keeps this suite off the real browser; the
// browser suite covers generation.
wayfernConfig: { fingerprint: "{}" },
groupId: null,
ephemeral: false,
dnsBlocklist: null,
launchHook: null,
});
const launchGroup = await jsonRequest(`${base}/v1/extension-groups`, {
method: "POST",
token: saved.api_token,
body: { name: "REST Launch Extension Group" },
});
assert.equal(
launchGroup.response.status,
201,
JSON.stringify(launchGroup.value),
);
const launchGroupId = launchGroup.value.id;
assert.deepEqual(
(
await jsonRequest(
`${base}/v1/extension-groups/${launchGroupId}/extensions/${archiveExtension.value.id}`,
{ method: "POST", token: saved.api_token },
)
).value.extension_ids,
[archiveExtension.value.id],
);
const assigned = await jsonRequest(
`${base}/v1/profiles/${launchProfile.id}`,
{
method: "PUT",
token: saved.api_token,
body: { extension_group_id: launchGroupId },
},
);
assert.equal(assigned.response.status, 200, JSON.stringify(assigned.value));
assert.equal(assigned.value.profile.id, launchProfile.id);
// `ApiProfile` carries no `extension_group_id`, so the assignment can only
// be read back through the surface the launcher itself resolves.
const assignedGroup = () =>
app.invoke("get_extension_group_for_profile", {
profileId: launchProfile.id,
});
assert.equal((await assignedGroup()).id, launchGroupId);
assert.deepEqual((await assignedGroup()).extension_ids, [
archiveExtension.value.id,
]);
// A group that does not exist used to be stored anyway and fail at launch,
// far from the request that caused it.
const missingExtensionGroup = await jsonRequest(
`${base}/v1/profiles/${launchProfile.id}`,
{
method: "PUT",
token: saved.api_token,
body: { extension_group_id: "00000000-0000-0000-0000-0000000000ee" },
},
);
assert.equal(
missingExtensionGroup.response.status,
404,
JSON.stringify(missingExtensionGroup.value),
);
assert.equal(
(await assignedGroup()).id,
launchGroupId,
"a refused assignment must leave the previous one in place",
);
assert.equal(
(
await jsonRequest(`${base}/v1/profiles/${launchProfile.id}`, {
method: "PUT",
token: saved.api_token,
body: { extension_group_id: "" },
})
).response.status,
200,
);
assert.equal(await assignedGroup(), null);
assert.equal(
(
await jsonRequest(`${base}/v1/extension-groups/${launchGroupId}`, {
method: "DELETE",
token: saved.api_token,
})
).response.status,
204,
);
await app.invoke("delete_profile", { profileId: launchProfile.id });
for (const id of [
extensionId,
archiveExtension.value.id,
oversized.value.id,
localized.value.id,
unresolved.value.id,
]) {
assert.equal(
(
await jsonRequest(`${base}/v1/extensions/${id}`, {
method: "DELETE",
token: saved.api_token,
})
).response.status,
204,
);
}
assert.equal(
(
await jsonRequest(`${base}/v1/extension-groups/${extensionGroupId}`, {
method: "DELETE",
token: saved.api_token,
})
).response.status,
204,
);
const missing = await jsonRequest(`${base}/v1/groups/missing`, {
token: saved.api_token,
});
@@ -323,6 +774,13 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a
"run_profile_remote",
"get_remote_session",
"stop_remote_session",
// Extension management is only usable from an agent if importing and
// grouping are reachable, not just listing and deleting.
"add_extension",
"update_extension",
"add_extension_to_group",
"remove_extension_from_group",
"update_extension_group",
]) {
assert.ok(names.includes(name), `MCP is missing ${name}`);
}
@@ -414,6 +872,101 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a
);
await app.invoke("delete_stored_proxy", { proxyId: vlessProxy.id });
let toolCallId = 7;
const callTool = (name, args) =>
jsonRequest(`${base}/mcp/${config.token}`, {
method: "POST",
headers: mcpHeaders,
body: {
jsonrpc: "2.0",
id: toolCallId++,
method: "tools/call",
params: { name, arguments: args },
},
});
const unpackedDir = await writeUnpackedExtension(
path.join(app.root, "fixtures", "mcp-unpacked-extension"),
{ name: "Donut MCP Unpacked", version: "1.0.0" },
);
const addedExtension = await callTool("add_extension", {
path: unpackedDir,
name: "MCP Folder Extension",
});
assert.equal(addedExtension.response.status, 200);
const subscriptionGated = /subscription/i.test(
addedExtension.value.error?.message ?? "",
);
// The e2e build overrides the paid-plan gate whenever a Wayfern test token
// is present, so with one in the environment a gated answer means the
// override stopped working and everything below it silently stopped
// running.
assert.ok(
!subscriptionGated || !process.env.WAYFERN_TEST_TOKEN,
`the e2e paid-plan override did not apply: ${addedExtension.value.error?.message}`,
);
if (subscriptionGated) {
// Every extension tool is gated on an active paid plan and this session
// is signed out, so the call path is unreachable here. The tool list
// above still proves the tools are published.
console.warn(
"Skipping the MCP extension tool calls: this session has no paid entitlement",
);
} else {
assert.equal(addedExtension.value.error, undefined);
const stored = (await app.invoke("list_extensions")).find(
(item) => item.name === "Donut MCP Unpacked",
);
assert.ok(stored, "the MCP import must produce a stored extension");
assert.equal(stored.source_kind, "unpacked");
assert.equal(stored.linked_path, null);
const renamedExtension = await callTool("update_extension", {
extension_id: stored.id,
name: "MCP Renamed Extension",
});
assert.equal(renamedExtension.value.error, undefined);
assert.equal(
(await app.invoke("list_extensions")).find(
(item) => item.id === stored.id,
).name,
"MCP Renamed Extension",
);
const extensionGroup = await app.invoke("create_extension_group", {
name: "MCP Extension Group",
});
const joined = await callTool("add_extension_to_group", {
group_id: extensionGroup.id,
extension_id: stored.id,
});
assert.equal(joined.value.error, undefined);
const readGroup = async () =>
(await app.invoke("list_extension_groups")).find(
(item) => item.id === extensionGroup.id,
);
assert.deepEqual((await readGroup()).extension_ids, [stored.id]);
const renamedGroup = await callTool("update_extension_group", {
group_id: extensionGroup.id,
name: "MCP Extension Group Updated",
});
assert.equal(renamedGroup.value.error, undefined);
assert.equal((await readGroup()).name, "MCP Extension Group Updated");
const removed = await callTool("remove_extension_from_group", {
group_id: extensionGroup.id,
extension_id: stored.id,
});
assert.equal(removed.value.error, undefined);
assert.deepEqual((await readGroup()).extension_ids, []);
await app.invoke("delete_extension", { extensionId: stored.id });
await app.invoke("delete_extension_group", {
groupId: extensionGroup.id,
});
}
const agents = await app.invoke("list_mcp_agents");
assert.ok(agents.some((agent) => agent.id === "cursor"));
await assertCommandErrorCode(app, "add_mcp_to_agent", "MCP_AGENT_UNKNOWN", {
+543 -1
View File
@@ -1,8 +1,15 @@
import assert from "node:assert/strict";
import { mkdir, realpath, writeFile } from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import Color from "color";
import en from "../../src/i18n/locales/en.json" with { type: "json" };
import { getDerivedThemeColors, THEMES } from "../../src/lib/themes.ts";
import { withApp } from "../lib/app.mjs";
import {
extensionZipBase64,
writeUnpackedExtension,
} from "../lib/fixtures.mjs";
const THEME_VARIABLES = [
"--background",
@@ -87,6 +94,21 @@ function themeVariablesEqual(actual, expected) {
async function applyThemeForContrastAudit(app, theme) {
await app.execute(
`
// This audit reads settled colour tokens, not the animation between
// them. Tab triggers carry "transition-colors duration-150" and start
// from --muted-foreground, so a computed style sampled mid-transition
// returns an intermediate colour and the assertion fails on whichever
// theme the machine happened to be slow on. Kill transitions for the
// duration of the audit rather than racing them with a fixed sleep.
let freeze = document.getElementById("donut-e2e-freeze-transitions");
if (!freeze) {
freeze = document.createElement("style");
freeze.id = "donut-e2e-freeze-transitions";
freeze.textContent =
"*, *::before, *::after { transition: none !important; animation: none !important; }";
document.head.appendChild(freeze);
}
const [colors, derived, mode] = arguments;
const root = document.documentElement;
root.classList.remove("light", "dark");
@@ -97,7 +119,11 @@ async function applyThemeForContrastAudit(app, theme) {
`,
[theme.colors, getDerivedThemeColors(theme.colors), theme.mode],
);
await new Promise((resolve) => setTimeout(resolve, 200));
// One frame is enough once transitions are off; the value cannot drift after
// style recalculation.
await app.execute(
`return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))));`,
);
}
async function animatedTabContrastSnapshot(app) {
@@ -556,6 +582,88 @@ test("VLESS proxy form keeps the share URI as one clear, validated input", async
});
});
test("pasting a proxy string into the form fills every field", async () => {
await withApp("ui-proxy-form-paste", async (app) => {
// Dispatched rather than typed: the point is that the paste is spread
// across the form instead of landing whole in the field it was dropped on,
// and only a real ClipboardEvent carries the text the handler reads.
const paste = (selector, text) =>
app.execute(
`const field = document.querySelector(arguments[0]);
const data = new DataTransfer();
data.setData("text/plain", arguments[1]);
field.focus();
return field.dispatchEvent(
new ClipboardEvent("paste", {
bubbles: true,
cancelable: true,
clipboardData: data,
}),
);`,
[selector, text],
);
const fieldValues = () =>
app.execute(
`return ["#proxy-name", "#proxy-host", "#proxy-port", "#proxy-username", "#proxy-password"]
.map((selector) => document.querySelector(selector)?.value ?? null);`,
);
await app.clickSelector('[aria-label="Network"]');
await app.waitForText("New proxy");
await app.clickSelector('[aria-label="New proxy"]');
await app.waitForText("Add Proxy");
await paste("#proxy-host", "socks5://carol:s3cret@1.2.3.4:1080");
await app.waitFor(async () => (await fieldValues())[1] === "1.2.3.4", {
description: "host filled from the pasted proxy",
});
assert.deepEqual(await fieldValues(), [
"1.2.3.4:1080",
"1.2.3.4",
"1080",
"carol",
"s3cret",
]);
assert.equal(
await app.execute(
`return document.querySelector("#proxy-type")?.textContent?.trim();`,
),
"SOCKS5",
);
// No scheme in the line, so the type falls back to HTTP.
await paste("#proxy-name", "5.6.7.8:8080:dave:hunter2");
await app.waitFor(async () => (await fieldValues())[1] === "5.6.7.8", {
description: "scheme-less proxy string parsed",
});
assert.deepEqual((await fieldValues()).slice(1), [
"5.6.7.8",
"8080",
"dave",
"hunter2",
]);
assert.equal(
await app.execute(
`return document.querySelector("#proxy-type")?.textContent?.trim();`,
),
"HTTP",
);
// A bare hostname is not a proxy string, so the form is left alone and the
// browser's own paste stands.
await paste("#proxy-host", "proxy.example.com");
// Settle the parse round-trip, so "nothing changed" isn't just "nothing
// has come back yet".
await app.invoke("get_stored_proxies");
assert.deepEqual((await fieldValues()).slice(1), [
"5.6.7.8",
"8080",
"dave",
"hunter2",
]);
});
});
test("About exposes a searchable, responsive third-party license inventory", async () => {
await withApp("ui-about-licenses", async (app) => {
await app.clickSelector('[aria-label="More"]');
@@ -931,3 +1039,437 @@ test("a light custom preset keeps light component behavior after restart", async
);
});
});
const EXTENSION_STRINGS = en.extensions;
/**
* Answer the native directory picker from inside the webview.
*
* "Load unpacked" calls `open({ directory: true })` from
* `@tauri-apps/plugin-dialog`, which puts an OS window on screen that no
* WebDriver can reach. The call leaves the page as the `plugin:dialog|open` IPC
* command, but Tauri locks its own entry points down: `invoke`, `ipc` and
* `postMessage` are all installed with
* `Object.defineProperty(window.__TAURI_INTERNALS__, name, { value })`, so they
* are non-writable and cannot be wrapped. The seam underneath them is the
* transport, which POSTs the command through `fetch` to
* `ipc://localhost/<command>`. Answering that one request with the shape Tauri
* expects (`Tauri-Response: ok` plus a JSON body) resolves the picker with a
* folder and needs no test-only hook in the production component. Every other
* command still reaches the real backend.
*/
async function stubFolderPicker(app, folder) {
await app.execute(
`const folder = arguments[0];
if (!window.__donutOriginalFetch) {
window.__donutOriginalFetch = window.fetch;
}
window.__donutFolderPickerCalls = [];
window.fetch = function (input, init) {
const url = String(
typeof input === "string" ? input : (input && input.url) || "",
);
let command = "";
try {
command = decodeURIComponent(url.split("/").pop() || "");
} catch (_error) {
command = "";
}
if (command === "plugin:dialog|open") {
let payload = null;
try {
payload = JSON.parse((init && init.body) || "null");
} catch (_error) {
payload = null;
}
window.__donutFolderPickerCalls.push(payload);
return Promise.resolve(
new Response(JSON.stringify(folder), {
status: 200,
headers: {
"content-type": "application/json",
"Tauri-Response": "ok",
},
}),
);
}
return window.__donutOriginalFetch.apply(window, arguments);
};
return true;`,
[folder],
);
}
/** Restores the real transport and returns what the picker was asked for. */
async function restoreFolderPicker(app) {
return app.execute(
`const calls = window.__donutFolderPickerCalls ?? [];
if (window.__donutOriginalFetch) {
window.fetch = window.__donutOriginalFetch;
delete window.__donutOriginalFetch;
}
delete window.__donutFolderPickerCalls;
return calls;`,
);
}
async function openExtensionsPage(app) {
await app.clickSelector('[aria-label="Extensions"]');
await app.waitFor(
() =>
app.execute(`return Boolean(document.querySelector(arguments[0]));`, [
`[aria-label="${EXTENSION_STRINGS.loadUnpacked}"]`,
]),
{ description: "extension management page" },
);
}
async function stageUnpackedFolder(app, folder) {
await stubFolderPicker(app, folder);
await app.clickSelector(`[aria-label="${EXTENSION_STRINGS.loadUnpacked}"]`);
await app.waitFor(
() =>
app.execute(
`return Boolean(document.querySelector("#ext-link-folder"));`,
),
{
description:
"staged folder import form (the intercepted directory picker has to resolve)",
},
);
}
async function uploadArchiveThroughUi(app, archivePath, typedName) {
// The real control is a hidden file input a button clicks for the user;
// WebDriver can only type a path into an input it can see.
await app.execute(`
const input = document.querySelector("#ext-file-input");
input.classList.remove("hidden");
input.style.position = "fixed";
input.style.left = "12px";
input.style.bottom = "12px";
`);
const input = await app.session.findCss("#ext-file-input");
await app.session.sendKeys(input, archivePath);
await app.waitForText(path.basename(archivePath));
await app.execute(`
const input = document.querySelector("#ext-file-input");
input.classList.add("hidden");
input.removeAttribute("style");
`);
await app.fillSelector(
`input[placeholder="${EXTENSION_STRINGS.namePlaceholder}"]`,
typedName,
);
await app.clickText(en.common.buttons.add, { roles: ["button"] });
}
/** The link checkbox plus the copy that is supposed to explain it. */
async function linkCheckboxState(app, id) {
return app.execute(
`const checkbox = document.querySelector("#" + arguments[0]);
const label = document.querySelector('label[for="' + arguments[0] + '"]');
const help = label?.parentElement?.querySelector("p");
return checkbox
? {
checked: checkbox.getAttribute("data-state") === "checked",
label: (label?.innerText ?? "").trim(),
help: (help?.innerText ?? "").trim(),
}
: null;`,
[id],
);
}
function extensionRowScript(body) {
return `const wanted = arguments[0];
const row = [...document.querySelectorAll("tbody tr")].find((candidate) => {
const cells = [...candidate.querySelectorAll("td")];
return cells.length >= 7 && (cells[2].innerText || "").trim() === wanted;
});
${body}`;
}
async function extensionRow(app, name) {
return app.execute(
extensionRowScript(`if (!row) return null;
const cells = [...row.querySelectorAll("td")];
const sync = row.querySelector('[data-slot="animated-switch"]');
return {
name: (cells[2].innerText || "").trim(),
source: (cells[4].innerText || "").trim(),
syncChecked: sync ? sync.getAttribute("data-state") === "checked" : null,
syncDisabled: sync ? sync.disabled === true : null,
};`),
[name],
);
}
async function extensionEditButton(app, name) {
return app.execute(
extensionRowScript(
`return row ? row.querySelector("td:last-child button") : null;`,
),
[name],
);
}
async function dialogText(app, title) {
return app.execute(
`const wanted = arguments[0];
const dialog = [...document.querySelectorAll("[role='dialog']")]
.reverse()
.find((node) =>
[...node.querySelectorAll("[data-slot='dialog-title']")].some(
(heading) => (heading.textContent || "").trim() === wanted,
),
);
return dialog ? (dialog.innerText || "").trim() : null;`,
[title],
);
}
async function toastTexts(app) {
return app.execute(
`return [...document.querySelectorAll("[data-sonner-toast]")]
.map((toast) => (toast.innerText || "").trim())
.filter(Boolean);`,
);
}
test("an uploaded archive and a loaded folder both import, each under its own source", async () => {
await withApp("ui-extension-import-sources", async (app) => {
const archivePath = path.join(app.root, "ui-archive-extension.zip");
await writeFile(archivePath, Buffer.from(extensionZipBase64(), "base64"));
const folder = await writeUnpackedExtension(
path.join(app.root, "fixtures", "ui-copied-extension"),
{ name: "Donut UI Copied Folder" },
);
await openExtensionsPage(app);
await uploadArchiveThroughUi(
app,
archivePath,
"Overridden By The Manifest",
);
await app.waitForText("Donut E2E Fixture");
await stageUnpackedFolder(app, folder);
assert.ok(await app.visibleTextIncludes(EXTENSION_STRINGS.selectedFolder));
assert.ok(
await app.visibleTextIncludes(folder),
"the staged import has to name the folder it is about to read",
);
const staged = await linkCheckboxState(app, "ext-link-folder");
assert.equal(staged?.checked, false, "linking a folder has to be opt-in");
assert.equal(staged.help, EXTENSION_STRINGS.linkFolderOff);
await app.clickText(en.common.buttons.add, { roles: ["button"] });
await app.waitForText("Donut UI Copied Folder");
const pickerCalls = await restoreFolderPicker(app);
assert.equal(pickerCalls.length, 1, "Load unpacked has to open the picker");
assert.equal(pickerCalls[0].options.directory, true);
assert.equal(pickerCalls[0].options.multiple, false);
assert.equal(
pickerCalls[0].options.title,
EXTENSION_STRINGS.selectFolderTitle,
);
assert.notEqual(
EXTENSION_STRINGS.source.archive,
EXTENSION_STRINGS.source.unpacked,
);
assert.equal(
(await extensionRow(app, "Donut E2E Fixture"))?.source,
EXTENSION_STRINGS.source.archive,
);
assert.equal(
(await extensionRow(app, "Donut UI Copied Folder"))?.source,
EXTENSION_STRINGS.source.unpacked,
);
const extensions = await app.invoke("list_extensions");
assert.equal(extensions.length, 2);
const copied = extensions.find(
(extension) => extension.name === "Donut UI Copied Folder",
);
assert.equal(copied.source_kind, "unpacked");
assert.equal(
copied.linked_path,
null,
"an unlinked folder import is copied into the store, not pointed at",
);
assert.equal(copied.file_type, "zip");
const archive = extensions.find(
(extension) => extension.name === "Donut E2E Fixture",
);
assert.equal(archive.source_kind, "archive");
assert.equal(archive.linked_path, null);
});
});
test("linking a folder says what it costs, records the path, and locks that row's sync off", async () => {
await withApp("ui-extension-linked-folder", async (app) => {
await app.invoke("add_extension", {
name: "Copied Neighbour",
fileName: "ui-neighbour-extension.zip",
fileData: [...Buffer.from(extensionZipBase64(), "base64")],
});
const folder = await writeUnpackedExtension(
path.join(app.root, "fixtures", "ui-linked-extension"),
{ name: "Donut UI Linked Folder" },
);
await openExtensionsPage(app);
await app.waitForText("Donut E2E Fixture");
await stageUnpackedFolder(app, folder);
const off = await linkCheckboxState(app, "ext-link-folder");
assert.equal(off?.checked, false);
assert.equal(off.label, EXTENSION_STRINGS.linkFolder);
assert.equal(off.help, EXTENSION_STRINGS.linkFolderOff);
await app.clickSelector("#ext-link-folder");
const on = await app.waitFor(
async () => {
const state = await linkCheckboxState(app, "ext-link-folder");
return state?.checked ? state : false;
},
{ description: "link checkbox to turn on" },
);
assert.equal(on.help, EXTENSION_STRINGS.linkFolderOn);
assert.notEqual(
on.help,
off.help,
"the checkbox has to say what turning it on changes",
);
await app.clickText(en.common.buttons.add, { roles: ["button"] });
await app.waitForText("Donut UI Linked Folder");
assert.equal((await restoreFolderPicker(app)).length, 1);
const linkedRow = await extensionRow(app, "Donut UI Linked Folder");
assert.equal(linkedRow?.source, EXTENSION_STRINGS.source.linked);
assert.equal(linkedRow.syncChecked, false);
assert.equal(linkedRow.syncDisabled, true);
assert.equal(
(await extensionRow(app, "Donut E2E Fixture"))?.syncDisabled,
false,
"only the linked row loses its sync control",
);
const linked = (await app.invoke("list_extensions")).find(
(extension) => extension.name === "Donut UI Linked Folder",
);
assert.equal(linked.linked_path, await realpath(folder));
assert.equal(linked.file_type, "unpacked");
assert.equal(linked.sync_enabled, false);
});
});
test("the edit dialog replaces an extension's payload from a folder", async () => {
await withApp("ui-extension-replace-from-folder", async (app) => {
const original = await app.invoke("add_extension", {
name: "Replaced Later",
fileName: "ui-original-extension.zip",
fileData: [...Buffer.from(extensionZipBase64(), "base64")],
});
assert.equal(original.version, "1.0.0");
const folder = await writeUnpackedExtension(
path.join(app.root, "fixtures", "ui-replacement-extension"),
{ name: "Donut UI Replacement", version: "3.1.4" },
);
await openExtensionsPage(app);
await app.waitForText("Donut E2E Fixture");
const editButton = await extensionEditButton(app, "Donut E2E Fixture");
assert.ok(editButton, "the extension row's edit control was not visible");
await app.clickElement(editButton, "extension edit button");
const beforeReplace = await app.waitFor(
() => dialogText(app, EXTENSION_STRINGS.editExtension),
{ description: "extension edit dialog" },
);
assert.ok(beforeReplace.includes(EXTENSION_STRINGS.source.label));
assert.ok(beforeReplace.includes(EXTENSION_STRINGS.source.archive));
await stubFolderPicker(app, folder);
await app.clickTextIn('[role="dialog"]', EXTENSION_STRINGS.selectFolder, {
roles: ["button"],
});
await app.waitFor(
async () =>
(await dialogText(app, EXTENSION_STRINGS.editExtension))?.includes(
folder,
),
{ description: "chosen replacement folder" },
);
const replaceLink = await linkCheckboxState(app, "ext-edit-link-folder");
assert.equal(replaceLink?.checked, false);
assert.equal(replaceLink.help, EXTENSION_STRINGS.linkFolderOff);
await app.clickTextIn('[role="dialog"]', en.common.buttons.save, {
roles: ["button"],
});
await app.waitFor(
async () =>
(await toastTexts(app)).some((text) =>
text.includes(EXTENSION_STRINGS.updateSuccess),
),
{ description: "extension update confirmation" },
);
assert.equal((await restoreFolderPicker(app)).length, 1);
const extensions = await app.invoke("list_extensions");
assert.equal(
extensions.length,
1,
"replacing a payload must not add a second extension",
);
const [updated] = extensions;
assert.equal(updated.id, original.id);
assert.equal(updated.source_kind, "unpacked");
assert.equal(updated.linked_path, null);
assert.equal(updated.file_name, "ui-replacement-extension.zip");
assert.equal(updated.version, "3.1.4");
// The dialog's own name field stays authoritative, so the row keeps its
// name while the payload underneath it is swapped.
assert.equal(updated.name, "Donut E2E Fixture");
await app.waitFor(
async () =>
(await extensionRow(app, "Donut E2E Fixture"))?.source ===
EXTENSION_STRINGS.source.unpacked,
{ description: "replaced row to report its new source" },
);
});
});
test("a folder with no manifest fails with the translated reason, not a raw code", async () => {
await withApp("ui-extension-manifest-missing", async (app) => {
const folder = path.join(app.root, "fixtures", "ui-not-an-extension");
await mkdir(folder, { recursive: true });
await writeFile(path.join(folder, "readme.txt"), "no manifest here\n");
await openExtensionsPage(app);
await stageUnpackedFolder(app, folder);
await app.clickText(en.common.buttons.add, { roles: ["button"] });
const expected = en.backendErrors.extensionManifestMissing;
await app.waitFor(
async () =>
(await toastTexts(app)).some((text) => text.includes(expected)),
{ description: "translated manifest-missing toast" },
);
const toasts = await toastTexts(app);
assert.ok(
toasts.every((text) => !text.includes("EXTENSION_MANIFEST_MISSING")),
`a raw backend code reached the user: ${JSON.stringify(toasts)}`,
);
assert.ok(
toasts.every((text) => !text.includes(EXTENSION_STRINGS.uploadFailed)),
"the generic fallback would hide which folder problem this was",
);
assert.deepEqual(await app.invoke("list_extensions"), []);
assert.equal((await restoreFolderPicker(app)).length, 1);
});
});
+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.4";
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.4/Donut_0.29.4_amd64.AppImage";
hash = "sha256-Y9HpvwsVXQ4gg4vUMPsjtLEd4Yu8sLK/W09mngtPajI=";
}
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.4/Donut_0.29.4_aarch64.AppImage";
hash = "sha256-duw3lDRLH7rYhOWn5SKn1bevsUIDLmXEs+wnyeiNt1c=";
}
else
null;
+4 -3
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.28.2",
"version": "0.29.5",
"type": "module",
"scripts": {
"predev": "pnpm licenses:generate",
@@ -10,10 +10,11 @@
"prebuild": "pnpm licenses:generate",
"build": "next build",
"start": "next start",
"test": "pnpm test:themes && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
"test": "pnpm test:themes && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:proxy-string && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
"test:themes": "node --test src/lib/themes.test.mjs",
"test:window-decorations": "node --test src/lib/window-decorations.test.mjs",
"test:cookie-bot-limits": "node --test src/lib/cookie-bot-limits.test.mjs",
"test:proxy-string": "node --test src/lib/proxy-string.test.mjs",
"test:licenses": "node --test scripts/generate-licenses.test.mjs && node scripts/generate-licenses.mjs --check",
"test:xray-packaging": "node --test src-tauri/download-xray.test.mjs",
"licenses:generate": "node scripts/generate-licenses.mjs",
@@ -111,7 +112,7 @@
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.3"
},
"packageManager": "pnpm@11.10.0",
"packageManager": "pnpm@11.21.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
+80 -5
View File
@@ -684,6 +684,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.4.2"
@@ -971,6 +980,15 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "cbc"
version = "0.2.1"
@@ -1797,7 +1815,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.28.2"
version = "0.29.5"
dependencies = [
"aes 0.9.1",
"aes-gcm 0.11.0",
@@ -1809,7 +1827,7 @@ dependencies = [
"blake3",
"boringtun",
"bzip2",
"cbc",
"cbc 0.2.1",
"chrono",
"chrono-tz",
"clap",
@@ -1844,6 +1862,8 @@ dependencies = [
"resvg",
"ring",
"rusqlite",
"secret-service",
"security-framework",
"serde",
"serde_json",
"serde_yaml",
@@ -3323,6 +3343,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding 0.3.3",
"generic-array",
]
@@ -3332,7 +3353,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [
"block-padding",
"block-padding 0.4.2",
"hybrid-array",
]
@@ -4011,6 +4032,20 @@ dependencies = [
"winapi",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
@@ -4021,6 +4056,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -4047,6 +4091,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
@@ -4093,7 +4147,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate 1.3.1",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
@@ -5613,6 +5667,25 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "secret-service"
version = "5.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14"
dependencies = [
"aes 0.8.4",
"cbc 0.1.2",
"futures-util",
"generic-array",
"getrandom 0.2.17",
"hkdf",
"num",
"once_cell",
"serde",
"sha2 0.10.9",
"zbus",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -6861,7 +6934,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -7036,6 +7109,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
]
@@ -8808,6 +8882,7 @@ dependencies = [
"rustix",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",
+10 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.28.2"
version = "0.29.5"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -119,9 +119,15 @@ nix = { version = "0.31", features = ["signal", "process"] }
# Reading the desktop's titlebar button layout for the in-app window controls.
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
# Reading the source browser's "<Brand> Safe Storage" secret during profile
# import, so its cookies and passwords can be re-encrypted for Wayfern.
secret-service = { version = "5", default-features = false, features = ["rt-tokio-crypto-rust"] }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
# Reading the source browser's "<Brand> Safe Storage" Keychain item during
# profile import, so its cookies and passwords can be re-encrypted for Wayfern.
security-framework = "3"
objc2 = "0.6.4"
objc2-app-kit = { version = "0.3.2", features = ["NSWindow", "NSApplication", "NSRunningApplication"] }
@@ -138,6 +144,9 @@ windows = { version = "0.62", features = [
"Win32_Storage_FileSystem",
"Win32_System_Registry",
"Win32_UI_Shell",
# CryptUnprotectData, for unwrapping the source browser's os_crypt key from
# `Local State` during profile import.
"Win32_Security_Cryptography",
] }
[dev-dependencies]
+66 -22
View File
@@ -60,8 +60,12 @@ export function requestedTarget() {
return match[1].trim();
}
function sha256(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex");
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function sha256File(path) {
return sha256(readFileSync(path));
}
export function xrayBinaryName(target) {
@@ -121,11 +125,60 @@ 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;
export 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})`,
);
}
// The response body is verified in memory and only then written out, so
// bytes that fail the pinned digest never reach the file system at all.
// Writing first and checking afterwards left an unverified archive on
// disk for the rest of the attempt, and any later reader of that path
// would have been trusting a plain network download.
const payload = Buffer.from(await response.arrayBuffer());
const actual = sha256(payload);
if (actual !== expectedSha256) {
throw new Error(
`Xray-core checksum mismatch: expected ${expectedSha256}, got ${actual}`,
);
}
writeFileSync(archive, payload);
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) {
// `target` comes from --target/$TARGET, and it decides the file this writes
// into src-tauri/binaries. Only an own key of the pinned table is a target;
// a plain lookup also answers for inherited names like `constructor`.
if (!Object.hasOwn(XRAY_ASSETS, target)) {
throw new Error(`Xray-core is not packaged for Rust target '${target}'`);
}
const asset = XRAY_ASSETS[target];
const windowsTarget = target.includes("windows");
const destinationDir = join(MANIFEST_DIR, "binaries");
@@ -143,8 +196,8 @@ export async function downloadXray(target = requestedTarget()) {
if (
source.version === XRAY_VERSION &&
source.archiveSha256 === asset.sha256 &&
source.binarySha256 === sha256(destination) &&
source.licenseSha256 === sha256(licenseDestination)
source.binarySha256 === sha256File(destination) &&
source.licenseSha256 === sha256File(licenseDestination)
) {
return destination;
}
@@ -157,20 +210,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)) {
@@ -189,8 +233,8 @@ export async function downloadXray(target = requestedTarget()) {
{
version: XRAY_VERSION,
archiveSha256: asset.sha256,
binarySha256: sha256(destination),
licenseSha256: sha256(licenseDestination),
binarySha256: sha256File(destination),
licenseSha256: sha256File(licenseDestination),
},
null,
2,
+60
View File
@@ -1,7 +1,12 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
downloadVerifiedArchive,
downloadXray,
windowsExtractionInvocation,
XRAY_ASSETS,
@@ -122,3 +127,58 @@ test("rejects an unsupported target before downloading", async () => {
/not packaged for Rust target/,
);
});
// `constructor`, `__proto__` and friends answer a plain `XRAY_ASSETS[target]`
// lookup, and `target` picks the path this script writes into src-tauri.
test("rejects inherited object keys as targets", async () => {
for (const target of ["__proto__", "constructor", "toString"]) {
await assert.rejects(
downloadXray(target),
/not packaged for Rust target/,
target,
);
}
});
async function withStubbedFetch(body, run) {
const scratch = mkdtempSync(join(tmpdir(), "donut-xray-test-"));
const archive = join(scratch, "Xray-linux-64.zip");
const realFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(body);
try {
await run(archive);
} finally {
globalThis.fetch = realFetch;
rmSync(scratch, { recursive: true, force: true });
}
}
test("writes the archive once the pinned digest matches", async () => {
const body = Buffer.from("xray archive bytes");
const digest = createHash("sha256").update(body).digest("hex");
await withStubbedFetch(body, async (archive) => {
await downloadVerifiedArchive(
"https://example.invalid/x.zip",
archive,
digest,
);
assert.deepEqual(readFileSync(archive), body);
});
});
// The bytes are hashed in memory and only then written, so a substituted or
// truncated response never lands on disk for a later step to pick up.
test("leaves nothing on disk when the payload fails its checksum", async () => {
await withStubbedFetch(Buffer.from("tampered"), async (archive) => {
await assert.rejects(
downloadVerifiedArchive(
"https://example.invalid/x.zip",
archive,
"0".repeat(64),
),
/checksum mismatch/,
);
assert.equal(existsSync(archive), false);
});
});
File diff suppressed because it is too large Load Diff
+146 -10
View File
@@ -37,10 +37,66 @@ fn data_root() -> Option<PathBuf> {
.map(PathBuf::from)
}
/// Log directory when `DONUTBROWSER_DATA_ROOT` is set (`<root>/logs`); `None`
/// otherwise, in which case the platform default app log dir is used.
/// Where logs go when something other than the platform default applies:
/// `<root>/logs` for `DONUTBROWSER_DATA_ROOT`, else `<exe dir>/logs` in
/// portable mode. `None` means the platform default app log dir.
///
/// Portable belongs here for the same reason `data_dir` and `cache_dir` honour
/// it: a portable install is expected to keep its state beside the executable.
/// Logs were the one thing still written to the host machine, which quietly
/// defeated that.
pub fn log_dir_override() -> Option<PathBuf> {
data_root().map(|root| root.join("logs"))
log_dir_for(data_root(), portable_dir())
}
/// Split out from `log_dir_override` so the precedence is testable without a
/// real `.portable` marker sitting next to the test binary.
fn log_dir_for(root: Option<PathBuf>, portable: Option<&PathBuf>) -> Option<PathBuf> {
if let Some(root) = root {
return Some(root.join("logs"));
}
portable.map(|dir| dir.join("logs"))
}
/// File name `tauri-plugin-window-state` persists geometry under.
pub const WINDOW_STATE_FILENAME: &str = ".window-state.json";
/// True when app state has been moved off the platform default location, by
/// portable mode or by either directory override.
fn state_is_relocated() -> bool {
std::env::var_os("DONUTBROWSER_DATA_DIR").is_some_and(|v| !v.is_empty())
|| data_root().is_some()
|| portable_dir().is_some()
}
/// Absolute path the window-state file should live at, or `None` to leave the
/// plugin on its platform default.
///
/// `tauri-plugin-window-state` resolves its file as
/// `app_config_dir().join(filename)` and exposes no way to change the
/// directory, so the only lever is the file name. Handing it an ABSOLUTE path
/// works because `Path::join` discards the base when the argument is absolute,
/// which lands the file with the rest of our relocated state instead of on the
/// host machine. If a future plugin version sanitises the name to a bare file
/// component this silently reverts to the default directory, which is why the
/// first-run probe in `lib.rs` reads this same function rather than assuming.
pub fn window_state_path_override() -> Option<PathBuf> {
state_is_relocated().then(|| data_dir().join(WINDOW_STATE_FILENAME))
}
/// Where the window-state file actually is, override or not. Used for the
/// first-run probe, which must agree with whatever the plugin was configured
/// with or portable installs re-apply the default geometry on every launch.
pub fn window_state_path<R: tauri::Runtime>(handle: &tauri::AppHandle<R>) -> Option<PathBuf> {
if let Some(path) = window_state_path_override() {
return Some(path);
}
use tauri::Manager;
handle
.path()
.app_config_dir()
.ok()
.map(|dir| dir.join(WINDOW_STATE_FILENAME))
}
pub fn app_name() -> &'static str {
@@ -262,19 +318,99 @@ mod tests {
#[test]
fn test_data_dir_returns_path() {
let dir = data_dir();
assert!(
dir.to_string_lossy().contains(app_name()),
"data_dir should contain app_name"
);
// Portable mode deliberately drops the app_name segment: state lives at
// <exe dir>/data. The assertion only holds for the platform-default path.
if is_portable() {
assert!(dir.ends_with("data"));
} else {
assert!(
dir.to_string_lossy().contains(app_name()),
"data_dir should contain app_name"
);
}
}
#[test]
fn test_cache_dir_returns_path() {
let dir = cache_dir();
assert!(
dir.to_string_lossy().contains(app_name()),
"cache_dir should contain app_name"
if is_portable() {
assert!(dir.ends_with("cache"));
} else {
assert!(
dir.to_string_lossy().contains(app_name()),
"cache_dir should contain app_name"
);
}
}
#[test]
fn log_dir_follows_portable_mode_and_data_root() {
let root = PathBuf::from("/tmp/donut-root");
let portable = PathBuf::from("/tmp/donut-portable");
// Neither: the platform default app log dir is used.
assert_eq!(log_dir_for(None, None), None);
// Portable alone keeps logs beside the executable rather than on the host.
assert_eq!(
log_dir_for(None, Some(&portable)),
Some(portable.join("logs"))
);
// DONUTBROWSER_DATA_ROOT wins over portable, matching data_dir/cache_dir.
assert_eq!(
log_dir_for(Some(root.clone()), Some(&portable)),
Some(root.join("logs"))
);
assert_eq!(
log_dir_for(Some(root.clone()), None),
Some(root.join("logs"))
);
}
#[test]
fn absolute_filename_escapes_the_plugin_base_dir() {
// The whole window-state redirect rests on this std behaviour: joining an
// absolute path discards the base. tauri-plugin-window-state does
// `app_config_dir().join(filename)`, so an absolute "filename" relocates
// the file. If this ever stops holding, the redirect silently stops too.
let base = PathBuf::from("/Users/someone/Library/Application Support/com.donutbrowser");
let absolute = PathBuf::from("/Volumes/Stick/Donut/data").join(WINDOW_STATE_FILENAME);
assert_eq!(base.join(&absolute), absolute);
assert!(!base.join(&absolute).starts_with(&base));
}
#[test]
fn window_state_stays_at_the_platform_default_for_a_normal_install() {
// A normal install must not be relocated: moving it would drop the window
// geometry every existing user already has.
if !state_is_relocated() {
assert_eq!(window_state_path_override(), None);
}
}
#[test]
fn window_state_follows_a_relocated_data_dir() {
let tmp = PathBuf::from("/tmp/donut-relocated");
let _guard = set_test_data_dir(tmp.clone());
// data_dir is overridden, so the file tracks it rather than app_config_dir.
assert_eq!(
data_dir().join(WINDOW_STATE_FILENAME),
tmp.join(".window-state.json")
);
}
#[test]
fn portable_keeps_data_cache_and_logs_under_one_root() {
// The three state directories must agree on where portable state lives, so
// a portable install leaves nothing behind on the host.
let portable = PathBuf::from("/tmp/donut-portable");
assert_eq!(
log_dir_for(None, Some(&portable)),
Some(portable.join("logs"))
);
assert!(portable.join("data").starts_with(&portable));
assert!(portable.join("cache").starts_with(&portable));
}
#[test]
+32 -1
View File
@@ -540,6 +540,25 @@ impl BrowserRunner {
let profiles_dir = self.profile_manager.get_profiles_dir();
let profile_data_path =
crate::ephemeral_dirs::get_effective_profile_path(&updated_profile, &profiles_dir);
// Profiles imported by builds before the layout fix have their content at
// the user-data-dir root instead of under `Default/`, so the browser has
// never seen a byte of it. Move it into place now, while the profile is
// provably not running. Secrets stay unreadable — the source key was
// never captured and cannot be recovered after the fact — but history,
// bookmarks, extensions and site data come back.
match crate::profile_import::repair_legacy_layout(&profile_data_path) {
Ok(true) => log::info!(
"Repaired legacy import layout for profile: {}",
updated_profile.name
),
Ok(false) => {}
Err(e) => log::warn!(
"Could not repair legacy import layout for {}: {e}",
updated_profile.name
),
}
let profile_path_str = profile_data_path.to_string_lossy().to_string();
// Install extensions if an extension group is assigned
@@ -1297,13 +1316,25 @@ impl BrowserRunner {
// disk instead of the previous snapshot.
crate::profile::password::complete_after_quit_and_wait(profile).await;
} else if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
let id = profile.id.to_string();
crate::ephemeral_dirs::remove_ephemeral_dir(&id);
// The per-domain traffic tracker writes to the cache dir on real disk
// regardless of where the profile itself lives, so an "in memory only"
// session still left a full record of everywhere it connected.
crate::traffic_stats::delete_traffic_stats(&id);
} else if profile.clear_on_close {
// Awaited for the same reason as re-encryption above: a queued sync
// must see the cleared dir, not the pre-clear snapshot.
crate::profile::clear_on_close::clear_profile_browsing_data(profile).await;
}
// The browser held these open for the life of the process; nothing reads
// them once it has exited, and they are plaintext extension code sitting
// on real disk even for an ephemeral profile.
crate::extension_manager::ExtensionManager::cleanup_unpacked_for_profile(
&profile.id.to_string(),
);
log::info!(
"Wayfern process cleanup completed for profile: {} (ID: {})",
profile.name,
+154 -16
View File
@@ -802,11 +802,39 @@ impl CloudAuthManager {
}
/// Account is in a paid/active state. Used for the "any active plan" gates
/// (sync token, wayfern token); per-feature access uses the capability helpers.
/// (sync token); per-feature access uses the capability helpers.
pub async fn has_active_paid_subscription(&self) -> bool {
#[cfg(feature = "e2e")]
if crate::e2e_automation_enabled()
&& std::env::var_os("WAYFERN_TEST_TOKEN").is_some_and(|token| !token.is_empty())
{
return true;
}
self.entitlements().await.map(|e| e.active).unwrap_or(false)
}
/// Whether this session's plan entitles it to a Wayfern automation token.
///
/// The token IS the automation entitlement, so this is `browser_automation`
/// and NOT `has_active_paid_subscription`. Gating the mint on "any active
/// plan" meant a Solo account — active, paying, and deliberately sold without
/// automation or fingerprint editing — asked for a token on every startup,
/// every login and every 10-hour refresh, collected a 403 each time, and got
/// the "account temporarily restricted" toast that belongs to the
/// multiple-device rule. Nothing was restricted; the plan simply does not
/// include the feature.
///
/// Reads the entitlement directly rather than going through
/// `can_use_browser_automation`, whose e2e override would send the browser
/// suite off to the live API for a token it already has as a test value.
pub async fn is_entitled_to_wayfern_token(&self) -> bool {
self
.entitlements()
.await
.is_some_and(|e| e.active && e.browser_automation)
}
/// Non-async version that uses try_lock, defaults to false if lock can't be acquired.
pub fn has_active_paid_subscription_sync(&self) -> bool {
match self.state.try_lock() {
@@ -1108,9 +1136,20 @@ impl CloudAuthManager {
.await
}
/// Request a wayfern token from the cloud API. Only succeeds for paid users.
/// Request a wayfern token from the cloud API. Only succeeds for plans that
/// include browser automation.
///
/// Self-gating on purpose: every caller used to repeat the check, and the one
/// they repeated was the wrong one. A plan without automation is not an error
/// state here — it clears any stale token and reports success, because there
/// is nothing to fetch and nothing wrong.
pub async fn request_wayfern_token(&self) -> Result<(), String> {
if !self.has_active_paid_subscription().await {
if !self.is_entitled_to_wayfern_token().await {
// Ok(()) here means callers log nothing, so a session that declined to
// mint left no trace at all and looked identical to one that succeeded.
log::info!(
"Skipping wayfern token request: the cached plan does not include browser automation"
);
self.clear_wayfern_token().await;
return Ok(());
}
@@ -1137,7 +1176,11 @@ impl CloudAuthManager {
if !response.status().is_success() {
let status = response.status();
return Err(format!("Wayfern token request failed ({status})"));
// The body carries WHICH rule refused: a device-family conflict or
// a plan that lacks automation. They need different handling, so
// keep the text instead of collapsing every failure to a status.
let body = response.text().await.unwrap_or_default();
return Err(format!("Wayfern token request failed ({status}): {body}"));
}
let result: WayfernTokenResponse = response
@@ -1156,12 +1199,21 @@ impl CloudAuthManager {
// A 403 rejects the entitlement without invalidating the login session.
// Clear the browser token and refresh account state before notifying UI.
if e.contains("(403") || e.contains("Forbidden") {
log::warn!("Wayfern token blocked by backend (403)");
log::warn!("Wayfern token blocked by backend (403): {e}");
self.clear_wayfern_token().await;
if let Err(fetch_err) = self.fetch_profile().await {
log::warn!("Profile re-fetch after wayfern block failed: {fetch_err}");
}
let _ = crate::events::emit_empty("wayfern-paid-blocked");
// Only the device rules produce a restriction the user can lift, and
// the toast tells them to sign other devices out — so only those may
// raise it. A plan-level refusal that slipped past the gate above
// (cached entitlements the re-fetch just corrected) must stay silent:
// telling a Solo customer they are "temporarily restricted" describes
// a lockout that does not exist and hides the real answer, which is
// that their plan does not include browser automation.
if is_device_restriction(&e) {
let _ = crate::events::emit_empty("wayfern-paid-blocked");
}
}
return Err(e);
}
@@ -1233,9 +1285,11 @@ impl CloudAuthManager {
}
}
// Refresh profile data periodically
// Refresh profile data periodically. A failure here leaves the cached
// plan stale, which silently gates paid features, so it belongs at warn
// rather than debug where the shipped log level hides it.
if let Err(e) = CLOUD_AUTH.fetch_profile().await {
log::debug!("Failed to refresh cloud profile: {e}");
log::warn!("Failed to refresh cloud profile: {e}");
}
// Reconnect profile lock manager if needed
@@ -1248,15 +1302,20 @@ impl CloudAuthManager {
// Sync cloud proxy credentials
CLOUD_AUTH.sync_cloud_proxy().await;
// Refresh wayfern token every 10 hours (60 iterations of 10-minute loop)
if wayfern_refresh_counter >= 60 {
// Refresh wayfern token every 10 hours (60 iterations of 10-minute loop).
// request_wayfern_token owns the entitlement check and clears the cached
// token when the plan doesn't include automation.
//
// Also mint one as soon as the plan starts granting it. `fetch_profile`
// above picks up an upgrade within ten minutes, but nothing watched that
// transition, so a session that signed in before upgrading stayed
// tokenless for up to ten hours while reporting the feature as unlocked.
let missing_entitled_token = CLOUD_AUTH.is_entitled_to_wayfern_token().await
&& CLOUD_AUTH.get_wayfern_token().await.is_none();
if wayfern_refresh_counter >= 60 || missing_entitled_token {
wayfern_refresh_counter = 0;
if CLOUD_AUTH.has_active_paid_subscription().await {
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to refresh wayfern token: {e}");
}
} else {
CLOUD_AUTH.clear_wayfern_token().await;
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to refresh wayfern token: {e}");
}
}
@@ -1265,6 +1324,17 @@ impl CloudAuthManager {
}
}
/// Whether a rejected wayfern-token request was refused by one of the
/// device-family rules (automation is pinned to the primary desktop session)
/// rather than by the plan's capabilities.
///
/// Matches on the backend's message because that is the only thing that
/// distinguishes them: both arrive as a bare 403. Only these two are a state
/// the user can clear themselves, which is what the toast asks them to do.
fn is_device_restriction(error: &str) -> bool {
error.contains("primary device") || error.contains("requires the desktop app")
}
fn solve_pow(prefix: &str, difficulty: u32) -> Option<String> {
if difficulty == 0 || difficulty > 32 {
return None;
@@ -1362,6 +1432,20 @@ pub async fn cloud_get_user() -> Result<Option<CloudAuthState>, String> {
pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
let mut user = CLOUD_AUTH.fetch_profile().await?;
user.entitlements = Some(user.entitlements());
// Minting the token is what actually unlocks cross-OS fingerprints, and it
// only happened at login, at startup and once every 10 hours. An account
// that upgraded after its last sign-in therefore refreshed into the correct
// entitlements while still holding no token, and "Refresh" did not fix it.
// Only mint when one is genuinely missing, so this stays a no-op afterwards.
if CLOUD_AUTH.is_entitled_to_wayfern_token().await
&& CLOUD_AUTH.get_wayfern_token().await.is_none()
{
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Refresh could not obtain a wayfern token: {e}");
}
}
Ok(user)
}
@@ -1586,3 +1670,57 @@ pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), St
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn active_solo() -> Entitlements {
derive_entitlements("solo", Some("monthly"), "active", 20)
}
#[test]
fn solo_is_active_without_browser_automation() {
let solo = active_solo();
assert!(solo.active, "solo is a paid, active plan");
assert!(solo.cloud_backup, "solo buys cloud profile backups");
assert!(solo.cookie_bot, "solo buys the nightly cookie bot");
assert!(
!solo.browser_automation,
"solo is sold without browser automation"
);
assert!(
!solo.cross_os_fingerprints,
"solo is sold without fingerprint editing"
);
}
#[test]
fn wayfern_token_is_gated_on_automation_not_on_being_paid() {
// The regression this guards: gating the mint on `active` asked for a token
// on behalf of a Solo account, which the backend answers with a 403.
let solo = active_solo();
assert!(!(solo.active && solo.browser_automation));
let pro = derive_entitlements("pro", Some("monthly"), "active", 50);
assert!(pro.active && pro.browser_automation);
}
#[test]
fn only_the_device_rules_read_as_a_restriction() {
assert!(is_device_restriction(
"Wayfern token request failed (403 Forbidden): {\"message\":\"Browser automation is restricted to your primary device. Log out other devices to use it here.\",\"statusCode\":403}"
));
assert!(is_device_restriction(
"Wayfern token request failed (403 Forbidden): {\"message\":\"Browser automation requires the desktop app. Open Donut Browser and try again.\",\"statusCode\":403}"
));
// A plan-level refusal is not a restriction, and must not raise the toast
// that tells the user to sign other devices out.
assert!(!is_device_restriction(
"Wayfern token request failed (403 Forbidden): {\"message\":\"Browser automation subscription required\",\"statusCode\":403}"
));
assert!(!is_device_restriction(
"Wayfern token request failed (500 Internal Server Error): "
));
}
}
+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;
+273 -37
View File
@@ -4,44 +4,110 @@ use std::sync::Mutex;
use crate::profile::BrowserProfile;
lazy_static::lazy_static! {
static ref EPHEMERAL_DIRS: Mutex<HashMap<String, PathBuf>> = Mutex::new(HashMap::new());
/// Whether an ephemeral directory is genuinely in memory, or was downgraded to
/// real disk because RAM backing could not be obtained.
///
/// This has to be recorded when the directory is created, not guessed when it
/// is destroyed: by teardown time the RAM disk may have been unmounted, and a
/// path alone cannot say what it used to be. The erase path reads it to decide
/// whether overwriting is meaningful or just page churn.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EphemeralBacking {
Ram,
Disk,
}
/// Get or create the RAM-backed base directory for ephemeral profiles.
/// Linux: /dev/shm (always tmpfs). macOS: RAM disk via hdiutil. Windows: imdisk RAM disk.
fn get_ephemeral_base_dir() -> Result<PathBuf, String> {
impl EphemeralBacking {
/// Overwriting only means something when freed disk blocks are involved.
fn needs_zeroing(self) -> bool {
matches!(self, EphemeralBacking::Disk)
}
}
struct EphemeralEntry {
path: PathBuf,
backing: EphemeralBacking,
}
lazy_static::lazy_static! {
static ref EPHEMERAL_DIRS: Mutex<HashMap<String, EphemeralEntry>> = Mutex::new(HashMap::new());
}
/// Test-only redirect for the ephemeral base.
///
/// Without this the unit tests call the real resolver, which on macOS runs
/// `hdiutil attach` + `diskutil erasevolume` and never detaches: a plain
/// `cargo test` left a 256 MB RAM disk mounted on the developer's machine
/// indefinitely. Deliberately compiled out of release builds, because an
/// env-var redirect for a directory holding decrypted profile data is a
/// capability nobody should be able to reach in a shipped binary.
#[cfg(any(test, debug_assertions))]
fn ephemeral_base_override() -> Option<PathBuf> {
std::env::var_os("DONUTBROWSER_EPHEMERAL_ROOT")
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}
#[cfg(not(any(test, debug_assertions)))]
fn ephemeral_base_override() -> Option<PathBuf> {
None
}
/// Get or create the base directory for ephemeral profiles, and report whether
/// it is actually RAM-backed.
///
/// Linux: /dev/shm (always tmpfs). macOS: RAM disk via hdiutil. Windows: imdisk
/// RAM disk, which is a third-party driver this app does not ship, so on most
/// Windows machines the disk fallback is the normal path rather than an edge
/// case. Callers must treat `Disk` as a downgrade and erase accordingly.
fn get_ephemeral_base_dir() -> Result<(PathBuf, EphemeralBacking), String> {
if let Some(base) = ephemeral_base_override() {
std::fs::create_dir_all(&base)
.map_err(|e| format!("Failed to create overridden ephemeral base: {e}"))?;
return Ok((base, EphemeralBacking::Disk));
}
#[cfg(target_os = "linux")]
{
let base = PathBuf::from("/dev/shm/donut-ephemeral");
std::fs::create_dir_all(&base)
.map_err(|e| format!("Failed to create ephemeral base in /dev/shm: {e}"))?;
Ok(base)
Ok((base, EphemeralBacking::Ram))
}
#[cfg(not(target_os = "linux"))]
{
let ramdisk_error: String;
#[cfg(target_os = "macos")]
{
if let Ok(mount) = get_or_create_macos_ramdisk() {
return Ok(mount);
match get_or_create_macos_ramdisk() {
Ok(mount) => return Ok((mount, EphemeralBacking::Ram)),
Err(e) => ramdisk_error = e,
}
log::warn!("Failed to create macOS RAM disk, ephemeral profiles may use disk");
}
#[cfg(target_os = "windows")]
{
if let Ok(mount) = get_or_create_windows_ramdisk() {
return Ok(mount);
match get_or_create_windows_ramdisk() {
Ok(mount) => return Ok((mount, EphemeralBacking::Ram)),
Err(e) => ramdisk_error = e,
}
log::warn!("Failed to create Windows RAM disk, ephemeral profiles may use disk");
}
// Fallback
// Downgraded to real disk. This is logged at error, with the cause and the
// destination, because the profile no longer keeps the promise its name
// makes and the previous "may use disk" wording was logged unconditionally
// right before disk was used, so it read as speculative when it was
// certain. The cause used to be discarded entirely.
let base = std::env::temp_dir().join("donut-ephemeral");
std::fs::create_dir_all(&base)
.map_err(|e| format!("Failed to create ephemeral base dir: {e}"))?;
Ok(base)
log::error!(
"No RAM disk available ({ramdisk_error}); ephemeral profiles are being written to disk at {} and will be securely erased on teardown instead",
base.display()
);
Ok((base, EphemeralBacking::Disk))
}
}
@@ -135,7 +201,7 @@ fn get_or_create_windows_ramdisk() -> Result<PathBuf, String> {
}
pub fn create_ephemeral_dir(profile_id: &str) -> Result<PathBuf, String> {
let base = get_ephemeral_base_dir()?;
let (base, backing) = get_ephemeral_base_dir()?;
let dir_path = base.join(profile_id);
std::fs::create_dir_all(&dir_path).map_err(|e| format!("Failed to create ephemeral dir: {e}"))?;
@@ -143,10 +209,23 @@ pub fn create_ephemeral_dir(profile_id: &str) -> Result<PathBuf, String> {
EPHEMERAL_DIRS
.lock()
.map_err(|e| format!("Failed to lock ephemeral dirs: {e}"))?
.insert(profile_id.to_string(), dir_path.clone());
.insert(
profile_id.to_string(),
EphemeralEntry {
path: dir_path.clone(),
backing,
},
);
// State the backing on every launch. Previously only the failure path said
// anything, so a log could not be used to tell a RAM-backed session from a
// disk-backed one after the fact.
log::info!(
"Created ephemeral dir for profile {}: {}",
"Created {} ephemeral dir for profile {}: {}",
match backing {
EphemeralBacking::Ram => "RAM-backed",
EphemeralBacking::Disk => "DISK-backed (not in memory)",
},
profile_id,
dir_path.display()
);
@@ -155,26 +234,67 @@ pub fn create_ephemeral_dir(profile_id: &str) -> Result<PathBuf, String> {
}
pub fn get_ephemeral_dir(profile_id: &str) -> Option<PathBuf> {
EPHEMERAL_DIRS.lock().ok()?.get(profile_id).cloned()
Some(EPHEMERAL_DIRS.lock().ok()?.get(profile_id)?.path.clone())
}
pub fn remove_ephemeral_dir(profile_id: &str) {
let dir = EPHEMERAL_DIRS
.lock()
.ok()
.and_then(|mut map| map.remove(profile_id));
/// Destroy a profile's ephemeral directory, zeroing it first when it is on real
/// disk.
///
/// Returns false when data was knowingly left behind. The mapping is only
/// dropped on success: removing it first (as this used to) meant a failure,
/// which on Windows is as ordinary as a file still being locked by an exiting
/// browser, discarded the only handle to the directory and guaranteed nothing
/// would ever retry it.
pub fn remove_ephemeral_dir(profile_id: &str) -> bool {
let entry = match EPHEMERAL_DIRS.lock() {
Ok(map) => map.get(profile_id).map(|e| (e.path.clone(), e.backing)),
Err(e) => {
log::error!("Failed to lock ephemeral dirs while removing {profile_id}: {e}");
return false;
}
};
if let Some(dir_path) = dir {
if dir_path.exists() {
if let Err(e) = std::fs::remove_dir_all(&dir_path) {
log::warn!("Failed to remove ephemeral dir {}: {e}", dir_path.display());
} else {
log::info!(
"Removed ephemeral dir for profile {}: {}",
profile_id,
dir_path.display()
);
let Some((dir_path, backing)) = entry else {
return true;
};
if !dir_path.exists() {
if let Ok(mut map) = EPHEMERAL_DIRS.lock() {
map.remove(profile_id);
}
return true;
}
let zero = backing.needs_zeroing();
let started = std::time::Instant::now();
match crate::fs_secure::secure_remove_dir_all(&dir_path, zero) {
Ok(files) => {
if let Ok(mut map) = EPHEMERAL_DIRS.lock() {
map.remove(profile_id);
}
log::info!(
"Removed {} ephemeral dir for profile {} ({} files, {} ms): {}",
if zero {
"and zeroed disk-backed"
} else {
"RAM-backed"
},
profile_id,
files,
started.elapsed().as_millis(),
dir_path.display()
);
true
}
Err(e) => {
// Error, not warn: this is the case where the user's browsing data is
// knowingly still on the machine. The mapping is kept so a later sweep
// can try again.
log::error!(
"Failed to remove ephemeral dir {} for profile {profile_id}: {e}. Profile data is still on disk.",
dir_path.display()
);
false
}
}
}
@@ -185,7 +305,7 @@ pub fn remove_ephemeral_dir(profile_id: &str) {
pub fn recover_ephemeral_dirs() {
cleanup_legacy_dirs();
let base = match get_ephemeral_base_dir() {
let (base, backing) = match get_ephemeral_base_dir() {
Ok(base) => base,
Err(e) => {
log::warn!("Cannot recover ephemeral dirs: {e}");
@@ -193,6 +313,12 @@ pub fn recover_ephemeral_dirs() {
}
};
// Sweep the disk fallback even when this run resolved to a RAM disk. A
// previous run that fell back left a full profile tree in the temp dir, and
// once RAM backing works again the base points elsewhere and that residue
// would never be looked at again.
sweep_disk_fallback_residue(&base);
let entries = match std::fs::read_dir(&base) {
Ok(entries) => entries,
Err(_) => return,
@@ -207,7 +333,15 @@ pub fn recover_ephemeral_dirs() {
if entry.path().is_dir() {
if let Some(name) = entry.file_name().to_str() {
if uuid::Uuid::parse_str(name).is_ok() {
dirs.insert(name.to_string(), entry.path());
dirs.insert(
name.to_string(),
EphemeralEntry {
path: entry.path(),
// Judge a recovered directory by the base it was found under, not
// by what some earlier run happened to resolve.
backing,
},
);
log::info!("Recovered ephemeral dir for profile {}", name);
}
}
@@ -215,6 +349,28 @@ pub fn recover_ephemeral_dirs() {
}
}
/// Securely erase leftovers from a run that was downgraded to the disk
/// fallback, unless that fallback is the base being used right now (in which
/// case `recover_ephemeral_dirs` is about to adopt them instead).
fn sweep_disk_fallback_residue(current_base: &Path) {
let fallback = std::env::temp_dir().join("donut-ephemeral");
if !fallback.exists() || fallback == current_base {
return;
}
match crate::fs_secure::secure_remove_dir_all(&fallback, true) {
Ok(files) if files > 0 => log::info!(
"Securely erased {files} file(s) of disk-backed ephemeral residue at {}",
fallback.display()
),
Ok(_) => {}
Err(e) => log::error!(
"Failed to erase disk-backed ephemeral residue at {}: {e}",
fallback.display()
),
}
}
/// Remove old-format ephemeral dirs from /tmp (pre-tmpfs migration).
fn cleanup_legacy_dirs() {
let temp_dir = std::env::temp_dir();
@@ -225,8 +381,10 @@ fn cleanup_legacy_dirs() {
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
// These are always in the system temp dir by construction, so they are
// always on real disk and always worth zeroing.
if name.starts_with("donut-ephemeral-") && entry.path().is_dir() {
if let Err(e) = std::fs::remove_dir_all(entry.path()) {
if let Err(e) = crate::fs_secure::secure_remove_dir_all(&entry.path(), true) {
log::warn!("Failed to clean up legacy ephemeral dir: {e}");
} else {
log::info!(
@@ -286,9 +444,34 @@ mod tests {
}
}
/// Point the ephemeral base at a scratch directory for the duration of a
/// test. Without this the tests call the real resolver, which on macOS
/// attaches a 256 MB RAM disk that nothing ever detaches, so running
/// `cargo test` left one mounted on the developer's machine indefinitely.
struct BaseGuard(tempfile::TempDir);
impl BaseGuard {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
std::env::set_var("DONUTBROWSER_EPHEMERAL_ROOT", tmp.path());
BaseGuard(tmp)
}
fn path(&self) -> &Path {
self.0.path()
}
}
impl Drop for BaseGuard {
fn drop(&mut self) {
std::env::remove_var("DONUTBROWSER_EPHEMERAL_ROOT");
}
}
#[test]
#[serial_test::serial]
fn test_ephemeral_dir_lifecycle() {
let _base = BaseGuard::new();
// Clear global state to avoid interference from other tests
EPHEMERAL_DIRS.lock().unwrap().clear();
@@ -321,7 +504,8 @@ mod tests {
#[test]
#[serial_test::serial]
fn test_recover_ephemeral_dirs() {
let base = get_ephemeral_base_dir().unwrap();
let _base = BaseGuard::new();
let (base, _) = get_ephemeral_base_dir().unwrap();
let test_id = uuid::Uuid::new_v4().to_string();
let test_dir = base.join(&test_id);
std::fs::create_dir_all(&test_dir).unwrap();
@@ -336,4 +520,56 @@ mod tests {
// Clean up
remove_ephemeral_dir(&test_id);
}
#[test]
#[serial_test::serial]
fn disk_backed_dirs_are_zeroed_and_ram_backed_ones_are_not() {
let base = BaseGuard::new();
EPHEMERAL_DIRS.lock().unwrap().clear();
// The override always reports Disk, which is the fail-safe: an unverified
// base must be treated as if it were on a platter.
let id = uuid::Uuid::new_v4().to_string();
let dir = create_ephemeral_dir(&id).unwrap();
// Proves the override actually took effect, so this test can never be
// silently exercising the developer's real RAM disk.
assert!(dir.starts_with(base.path()));
assert_eq!(
EPHEMERAL_DIRS.lock().unwrap().get(&id).map(|e| e.backing),
Some(EphemeralBacking::Disk)
);
assert!(EphemeralBacking::Disk.needs_zeroing());
assert!(!EphemeralBacking::Ram.needs_zeroing());
std::fs::write(dir.join("Cookies"), b"session=secret").unwrap();
assert!(remove_ephemeral_dir(&id));
assert!(!dir.exists());
assert!(get_ephemeral_dir(&id).is_none());
}
#[test]
#[serial_test::serial]
fn removing_an_unknown_profile_succeeds_without_doing_anything() {
let _base = BaseGuard::new();
EPHEMERAL_DIRS.lock().unwrap().clear();
assert!(remove_ephemeral_dir(&uuid::Uuid::new_v4().to_string()));
}
#[test]
#[serial_test::serial]
fn the_mapping_survives_a_failed_removal_so_it_can_be_retried() {
// The old code popped the entry before attempting the delete, so a failure
// (a locked file on Windows, say) threw away the only handle to the
// directory and nothing could ever retry it.
let _base = BaseGuard::new();
EPHEMERAL_DIRS.lock().unwrap().clear();
let id = uuid::Uuid::new_v4().to_string();
let dir = create_ephemeral_dir(&id).unwrap();
assert!(dir.exists());
// A successful removal is the one that clears the mapping.
assert!(remove_ephemeral_dir(&id));
assert!(get_ephemeral_dir(&id).is_none());
}
}
File diff suppressed because it is too large Load Diff
+213
View File
@@ -0,0 +1,213 @@
//! Best-effort secure deletion.
//!
//! "Best-effort" is load-bearing and is not a hedge. On copy-on-write
//! filesystems (APFS, Btrfs, ZFS, ReFS) and on any SSD with wear levelling, the
//! blocks holding the old contents may survive an overwrite entirely, because
//! the write lands somewhere else. RAM-backed storage can also be paged out,
//! and zeroing a file cannot reach the swap slot that held it. Treat these
//! helpers as raising the cost of recovery, never as a guarantee of erasure.
//!
//! The only reliable erasure this codebase has is not writing plaintext to disk
//! in the first place, which is what the RAM-backed ephemeral directories are
//! for. These helpers exist for the paths where that failed.
use std::fs;
use std::io::Write;
use std::path::Path;
/// Zero a file's bytes and flush before unlinking, so the contents are not
/// trivially recoverable from the freed blocks.
///
/// The overwrite must never gate the unlink. A write that fails part-way
/// (ENOSPC on a copy-on-write volume, EIO) would otherwise leave the file both
/// un-wiped and un-deleted, which is strictly worse than the plain remove this
/// replaces, because callers report success either way and the data would
/// silently survive.
pub fn secure_remove_file(path: &Path) -> std::io::Result<()> {
if let Ok(meta) = fs::metadata(path) {
let len = meta.len();
if len > 0 {
if let Ok(mut f) = fs::OpenOptions::new().write(true).open(path) {
let zeros = vec![0u8; 64 * 1024];
let mut remaining = len;
while remaining > 0 {
let chunk = remaining.min(zeros.len() as u64) as usize;
if f.write_all(&zeros[..chunk]).is_err() {
break;
}
remaining -= chunk as u64;
}
// One flush per file, not per chunk: syncing every 64 KiB turns a
// profile teardown into thousands of barriers for no extra safety.
let _ = f.flush();
let _ = f.sync_all();
}
}
}
fs::remove_file(path)
}
/// Whether zeroing this file would even mean anything.
///
/// A file with more than one hard link is still reachable through the other
/// link, so overwriting it destroys live data somewhere else and erases
/// nothing here.
#[cfg(unix)]
fn is_last_link(meta: &fs::Metadata) -> bool {
use std::os::unix::fs::MetadataExt;
meta.nlink() <= 1
}
#[cfg(not(unix))]
fn is_last_link(_meta: &fs::Metadata) -> bool {
true
}
/// Recursively delete a directory, optionally zeroing regular files first.
///
/// `zero` should be false for RAM-backed storage (tmpfs, a real RAM disk):
/// there are no freed disk blocks to scrub, so overwriting is pure page churn
/// and on a small fixed-size volume can hit ENOSPC. Pass true only when the
/// tree is genuinely on disk.
///
/// Symlinks are unlinked, never followed and never zeroed: following one would
/// destroy a target outside the tree.
///
/// Returns the number of files removed. The tree is removed even when
/// individual steps fail, because leaving a half-wiped directory in place is
/// the worst outcome available.
pub fn secure_remove_dir_all(root: &Path, zero: bool) -> std::io::Result<u64> {
let mut removed = 0u64;
if !root.exists() {
return Ok(0);
}
remove_tree(root, zero, &mut removed);
// Unconditional backstop: a walk that failed part-way must still not leave
// the directory behind.
match fs::remove_dir_all(root) {
Ok(()) => Ok(removed),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(removed),
Err(e) => Err(e),
}
}
fn remove_tree(dir: &Path, zero: bool, removed: &mut u64) {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) => {
log::warn!("Secure erase could not read {}: {e}", dir.display());
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
// file_type() on the DirEntry is lstat-based, so a symlink reports as a
// symlink rather than as whatever it points at.
let file_type = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if file_type.is_symlink() {
let _ = fs::remove_file(&path);
*removed += 1;
} else if file_type.is_dir() {
remove_tree(&path, zero, removed);
let _ = fs::remove_dir(&path);
} else {
let should_zero = zero
&& fs::symlink_metadata(&path)
.map(|m| is_last_link(&m))
.unwrap_or(false);
let outcome = if should_zero {
secure_remove_file(&path)
} else {
fs::remove_file(&path)
};
if outcome.is_ok() {
*removed += 1;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zeroing_erase_removes_a_nested_tree() {
let tmp = tempfile::tempdir().unwrap();
let nested = tmp.path().join("Default/Network");
fs::create_dir_all(&nested).unwrap();
let cookies = nested.join("Cookies");
fs::write(&cookies, b"session=supersecretvalue").unwrap();
let removed = secure_remove_dir_all(tmp.path(), true).unwrap();
assert!(
removed >= 1,
"expected at least the cookie file to be counted"
);
assert!(!tmp.path().exists());
}
#[test]
fn erase_without_zeroing_still_removes_everything() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("a"), b"x").unwrap();
fs::create_dir_all(tmp.path().join("d")).unwrap();
fs::write(tmp.path().join("d/b"), b"y").unwrap();
secure_remove_dir_all(tmp.path(), false).unwrap();
assert!(!tmp.path().exists());
}
#[test]
fn missing_root_is_not_an_error() {
let tmp = tempfile::tempdir().unwrap();
let absent = tmp.path().join("never-existed");
assert_eq!(secure_remove_dir_all(&absent, true).unwrap(), 0);
}
#[cfg(unix)]
#[test]
fn a_symlink_is_unlinked_without_touching_its_target() {
use std::os::unix::fs::symlink;
// The target lives OUTSIDE the tree being erased. Following the link would
// destroy a user's real file, which is the failure this guards against.
let outside = tempfile::tempdir().unwrap();
let target = outside.path().join("precious");
fs::write(&target, b"must survive intact").unwrap();
let tmp = tempfile::tempdir().unwrap();
symlink(&target, tmp.path().join("link")).unwrap();
secure_remove_dir_all(tmp.path(), true).unwrap();
assert!(!tmp.path().exists());
assert_eq!(fs::read(&target).unwrap(), b"must survive intact");
}
#[cfg(unix)]
#[test]
fn a_second_hard_link_is_not_zeroed_through() {
let tmp = tempfile::tempdir().unwrap();
let inside = tmp.path().join("shared");
fs::write(&inside, b"still referenced elsewhere").unwrap();
let outside = tempfile::tempdir().unwrap();
let other = outside.path().join("other-name");
fs::hard_link(&inside, &other).unwrap();
secure_remove_dir_all(tmp.path(), true).unwrap();
// The link inside the tree is gone, and the surviving link still holds the
// original bytes rather than a run of zeros.
assert!(!tmp.path().exists());
assert_eq!(fs::read(&other).unwrap(), b"still referenced elsewhere");
}
}
+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);
+37 -18
View File
@@ -70,6 +70,7 @@ mod ephemeral_dirs;
mod extension_manager;
mod extraction;
mod fingerprint_consistency;
mod fs_secure;
mod geoip_downloader;
mod geolocation;
mod group_manager;
@@ -80,6 +81,7 @@ mod launch_gate_prefs;
mod log_redaction;
mod platform_browser;
mod profile;
mod profile_import;
mod profile_importer;
mod proxy_manager;
pub mod proxy_runner;
@@ -186,9 +188,10 @@ use profile_importer::{
};
use extension_manager::{
add_extension, add_extension_to_group, assign_extension_group_to_profile, create_extension_group,
delete_extension, delete_extension_group, get_extension_group_for_profile, get_extension_icon,
list_extension_groups, list_extensions, remove_extension_from_group, update_extension,
add_extension, add_extension_to_group, add_unpacked_extension, assign_extension_group_to_profile,
create_extension_group, delete_extension, delete_extension_group,
get_extension_group_for_profile, get_extension_icon, list_extension_groups, list_extensions,
remove_extension_from_group, update_extension, update_extension_from_path,
update_extension_group,
};
@@ -1714,8 +1717,9 @@ pub fn run_with_builder(
let log_file_name = app_dirs::app_name();
// Honor DONUTBROWSER_DATA_ROOT: when set, logs go to <root>/logs instead of
// the platform default app log dir, so all on-disk state lives under one root.
// Honor DONUTBROWSER_DATA_ROOT and portable mode: logs go to <root>/logs or
// <exe dir>/logs instead of the platform default app log dir, so all on-disk
// state lives under one root rather than leaking onto the host machine.
let file_log_target = match app_dirs::log_dir_override() {
Some(path) => Target::new(TargetKind::Folder {
path,
@@ -1788,9 +1792,23 @@ pub fn run_with_builder(
// (the green button zooms instead) — the maximized flag captures the
// "filled screen" state, including green-button zoom on macOS.
.plugin(
tauri_plugin_window_state::Builder::default()
.with_state_flags(
tauri_plugin_window_state::StateFlags::all()
{
let mut window_state = tauri_plugin_window_state::Builder::default();
// Keep window geometry with the rest of the relocated state instead of
// the host's app-config dir. The plugin only lets us name the file, so
// the name is an absolute path; see `window_state_path_override`.
if let Some(path) = app_dirs::window_state_path_override() {
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
log::warn!("Failed to create the window-state directory: {e}");
}
}
window_state = window_state.with_filename(path.to_string_lossy().into_owned());
}
window_state
}
.with_state_flags(
tauri_plugin_window_state::StateFlags::all()
& !tauri_plugin_window_state::StateFlags::VISIBLE
& !tauri_plugin_window_state::StateFlags::FULLSCREEN
// Whether the window is decorated is decided per-session by
@@ -1798,8 +1816,8 @@ pub fn run_with_builder(
// a previous run saved. Restoring it would put a real titlebar back
// on top of the one the app draws — or strip both.
& !tauri_plugin_window_state::StateFlags::DECORATIONS,
)
.build(),
)
.build(),
);
builder.setup(|app| {
@@ -1906,10 +1924,11 @@ pub fn run_with_builder(
// saved, that geometry is the user's and has already been restored —
// re-applying the default here would move and resize their window on
// every launch, and the plugin would then persist the reset.
let has_saved_geometry = app
.path()
.app_config_dir()
.map(|dir| dir.join(".window-state.json").exists())
// Must resolve through the same helper the plugin was configured with:
// probing the platform default while the plugin writes elsewhere would
// read "first run" on every launch and reset the user's window.
let has_saved_geometry = app_dirs::window_state_path(app.handle())
.map(|path| path.exists())
.unwrap_or(false);
if window_decorations::use_client_side_decorations() && !has_saved_geometry {
if let Err(e) = window.set_size(tauri::LogicalSize::new(880.0, 500.0)) {
@@ -2621,10 +2640,8 @@ pub fn run_with_builder(
cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
};
let wayfern_fut = async {
if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await {
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to request wayfern token on startup: {e}");
}
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to request wayfern token on startup: {e}");
}
};
tokio::join!(sync_token_fut, proxy_fut, wayfern_fut);
@@ -2734,7 +2751,9 @@ pub fn run_with_builder(
list_extensions,
get_extension_icon,
add_extension,
add_unpacked_extension,
update_extension,
update_extension_from_path,
delete_extension,
list_extension_groups,
create_extension_group,
+270 -2
View File
@@ -746,6 +746,14 @@ impl McpServer {
"vpn_id": {
"type": "string",
"description": "Optional VPN UUID to assign to this profile"
},
"browser_type": {
"type": "string",
"description": "Source browser family (chromium, brave, edge, vivaldi, opera, arc, yandex, ...). Selects which OS keychain entry holds the key that unlocks the source's cookies and passwords, so an accurate value is what makes secrets survive the import"
},
"allow_running": {
"type": "boolean",
"description": "Import even though the source browser is running. Databases are still snapshotted consistently, but site data stored in LevelDB may be captured mid-write"
}
},
"required": ["source_path", "new_profile_name"]
@@ -1299,6 +1307,33 @@ impl McpServer {
"required": []
}),
},
McpTool {
name: "add_extension".to_string(),
description: "Add a managed browser extension from a path on the machine running Donut: a .crx or .zip archive file, or an unpacked extension folder holding a top-level manifest.json. With link set to true, which only applies to a folder, the folder is loaded in place instead of being copied into Donut, so edits to it apply on the next browser start and the extension is machine-local and never synced. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path on the machine running Donut to a .crx/.zip file or to an unpacked extension folder" },
"name": { "type": "string", "description": "Display name, used only when the manifest carries no name of its own" },
"link": { "type": "boolean", "description": "Folders only: load the folder in place instead of copying it into Donut. Linked extensions never sync. Defaults to false." }
},
"required": ["path"]
}),
},
McpTool {
name: "update_extension".to_string(),
description: "Rename a managed extension and/or replace its payload from a path on the machine running Donut: a .crx or .zip archive file, or an unpacked extension folder holding a top-level manifest.json. With link set to true, which only applies to a folder, the folder is loaded in place instead of being copied into Donut, so the extension becomes machine-local and never syncs. At least one of name or path must be given. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"extension_id": { "type": "string", "description": "The extension ID to update" },
"name": { "type": "string", "description": "New display name" },
"path": { "type": "string", "description": "Path on the machine running Donut to the .crx/.zip file or unpacked extension folder to replace the payload with" },
"link": { "type": "boolean", "description": "Folders only: load the folder in place instead of copying it into Donut. Linked extensions never sync. Defaults to false." }
},
"required": ["extension_id"]
}),
},
McpTool {
name: "create_extension_group".to_string(),
description: "Create a new extension group. Requires Pro subscription.".to_string(),
@@ -1310,6 +1345,47 @@ impl McpServer {
"required": ["name"]
}),
},
McpTool {
name: "update_extension_group".to_string(),
description: "Rename an extension group and/or replace its membership with an exact list of extension IDs. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"group_id": { "type": "string", "description": "The extension group ID to update" },
"name": { "type": "string", "description": "New name for the extension group" },
"extension_ids": {
"type": "array",
"items": { "type": "string" },
"description": "The complete set of extension IDs the group should contain, replacing the current membership"
}
},
"required": ["group_id"]
}),
},
McpTool {
name: "add_extension_to_group".to_string(),
description: "Add an extension to an extension group. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"group_id": { "type": "string", "description": "The extension group ID" },
"extension_id": { "type": "string", "description": "The extension ID to add to the group" }
},
"required": ["group_id", "extension_id"]
}),
},
McpTool {
name: "remove_extension_from_group".to_string(),
description: "Remove an extension from an extension group. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"group_id": { "type": "string", "description": "The extension group ID" },
"extension_id": { "type": "string", "description": "The extension ID to remove from the group" }
},
"required": ["group_id", "extension_id"]
}),
},
McpTool {
name: "delete_extension".to_string(),
description: "Delete a managed extension. Requires Pro subscription.".to_string(),
@@ -2207,7 +2283,12 @@ impl McpServer {
// Extension management
"list_extensions" => self.handle_list_extensions().await,
"list_extension_groups" => self.handle_list_extension_groups().await,
"add_extension" => self.handle_add_extension(arguments).await,
"update_extension" => self.handle_update_extension(arguments).await,
"create_extension_group" => self.handle_create_extension_group(arguments).await,
"update_extension_group" => self.handle_update_extension_group(arguments).await,
"add_extension_to_group" => self.handle_add_extension_to_group(arguments).await,
"remove_extension_from_group" => self.handle_remove_extension_from_group(arguments).await,
"delete_extension" => self.handle_delete_extension_mcp(arguments).await,
"delete_extension_group" => self.handle_delete_extension_group_mcp(arguments).await,
"assign_extension_group_to_profile" => {
@@ -4394,6 +4475,88 @@ impl McpServer {
Ok(serde_json::to_value(groups).unwrap())
}
async fn handle_add_extension(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let path = arguments
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: path".to_string(),
})?;
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let link = arguments
.get("link")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let extension = mgr
.add_extension_from_path(name, std::path::Path::new(path), link)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to add extension: {e}"),
})?;
Ok(serde_json::to_value(extension).unwrap())
}
async fn handle_update_extension(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let extension_id = arguments
.get("extension_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: extension_id".to_string(),
})?;
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.map(str::to_string);
let path = arguments.get("path").and_then(|v| v.as_str());
if name.is_none() && path.is_none() {
return Err(McpError {
code: -32602,
message: "Provide at least one of: name, path".to_string(),
});
}
let link = arguments
.get("link")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let extension = match path {
Some(path) => {
mgr.update_extension_from_path(extension_id, name, std::path::Path::new(path), link)
}
None => mgr.update_extension(extension_id, name, None, None),
}
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update extension: {e}"),
})?;
Ok(serde_json::to_value(extension).unwrap())
}
async fn handle_create_extension_group(
&self,
arguments: &serde_json::Value,
@@ -4419,6 +4582,106 @@ impl McpServer {
Ok(serde_json::to_value(group).unwrap())
}
async fn handle_update_extension_group(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let group_id = arguments
.get("group_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: group_id".to_string(),
})?;
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.map(str::to_string);
let extension_ids = arguments
.get("extension_ids")
.and_then(|v| v.as_array())
.map(|ids| {
ids
.iter()
.filter_map(|id| id.as_str().map(str::to_string))
.collect::<Vec<String>>()
});
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let group = mgr
.update_group(group_id, name, extension_ids)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update extension group: {e}"),
})?;
Ok(serde_json::to_value(group).unwrap())
}
async fn handle_add_extension_to_group(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let (group_id, extension_id) = Self::group_and_extension_ids(arguments)?;
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let group = mgr
.add_extension_to_group(group_id, extension_id)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to add extension to group: {e}"),
})?;
Ok(serde_json::to_value(group).unwrap())
}
async fn handle_remove_extension_from_group(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let (group_id, extension_id) = Self::group_and_extension_ids(arguments)?;
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let group = mgr
.remove_extension_from_group(group_id, extension_id)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to remove extension from group: {e}"),
})?;
Ok(serde_json::to_value(group).unwrap())
}
fn group_and_extension_ids(arguments: &serde_json::Value) -> Result<(&str, &str), McpError> {
let group_id = arguments
.get("group_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: group_id".to_string(),
})?;
let extension_id = arguments
.get("extension_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: extension_id".to_string(),
})?;
Ok((group_id, extension_id))
}
async fn handle_delete_extension_mcp(
&self,
arguments: &serde_json::Value,
@@ -6027,9 +6290,9 @@ mod tests {
let server = McpServer::new();
let tools = server.get_tools();
// Should have at least 54 tools (34 + 7 browser interaction + 13 remote
// Should have at least 59 tools (39 + 7 browser interaction + 13 remote
// fleet and cookie-bot tools)
assert!(tools.len() >= 54);
assert!(tools.len() >= 59);
// Names are the contract an MCP client is written against, so a duplicate
// silently shadows one of the two in dispatch and the tool that loses is
@@ -6084,7 +6347,12 @@ mod tests {
// Extension tools
assert!(tool_names.contains(&"list_extensions"));
assert!(tool_names.contains(&"list_extension_groups"));
assert!(tool_names.contains(&"add_extension"));
assert!(tool_names.contains(&"update_extension"));
assert!(tool_names.contains(&"create_extension_group"));
assert!(tool_names.contains(&"update_extension_group"));
assert!(tool_names.contains(&"add_extension_to_group"));
assert!(tool_names.contains(&"remove_extension_from_group"));
assert!(tool_names.contains(&"delete_extension"));
assert!(tool_names.contains(&"delete_extension_group"));
assert!(tool_names.contains(&"assign_extension_group_to_profile"));
+37 -9
View File
@@ -2,7 +2,7 @@ use crate::browser::{create_browser, BrowserType};
use crate::cloud_auth::CLOUD_AUTH;
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
use crate::events;
use crate::profile::types::{get_host_os, BrowserProfile, SyncMode};
use crate::profile::types::{get_host_os, is_host_os, BrowserProfile, SyncMode};
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::WayfernConfig;
use std::fs::{self, create_dir_all};
@@ -384,11 +384,23 @@ impl ProfileManager {
};
// Backfill host_os from browser config for profiles created before
// the field existed (or synced without it).
if profile.host_os.is_none() {
let inferred_os = profile.resolved_os().map(str::to_string);
if let Some(os) = inferred_os {
profile.host_os = Some(os);
// the field existed (or synced without it), and repair any profile
// already stamped with a fingerprint-only OS.
//
// Only a real host OS may be stored here. The fallback in
// `resolved_os` reads `wayfern_config.os`, which is a fingerprint OS
// and may be "android"/"ios". Persisting that made `is_cross_os`
// permanently true and locked the profile out of every local launch,
// with no way to undo it from the UI. Leaving `host_os` as None keeps
// the profile launchable, which is what it was before the field.
let needs_repair = profile.host_os.as_deref().is_some_and(|os| !is_host_os(os));
if profile.host_os.is_none() || needs_repair {
let inferred_os = profile
.resolved_os()
.filter(|os| is_host_os(os))
.map(str::to_string);
if inferred_os != profile.host_os {
profile.host_os = inferred_os;
if let Ok(json) = serde_json::to_string_pretty(&profile) {
let _ = atomic_write(&metadata_file, json.as_bytes());
}
@@ -483,6 +495,18 @@ impl ProfileManager {
// so nothing else would ever clean them up.
crate::launch_gate_prefs::forget_profile(profile_id);
// Deleting the profile never touched its ephemeral directory, so a
// decrypted or in-memory copy outlived the profile it belonged to with
// nothing left that knew to reap it. The running-browser guard above only
// rejects a live process_id, and the keep-decrypted path deliberately
// clears process_id while leaving the plaintext tree populated. No-ops
// when the profile has no ephemeral directory.
crate::ephemeral_dirs::remove_ephemeral_dir(profile_id);
// Per-domain traffic history lives outside the profile directory, so it
// survives the delete otherwise. It is already zero-overwritten on removal.
crate::traffic_stats::delete_traffic_stats(profile_id);
// Remember sync mode before deleting local files
let was_sync_enabled = profile.is_sync_enabled();
@@ -1546,7 +1570,11 @@ impl ProfileManager {
None => {
// No running instance found, clear process ID if set
if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
let id = profile.id.to_string();
crate::ephemeral_dirs::remove_ephemeral_dir(&id);
// Destination history is kept outside the profile dir, so erasing
// the profile alone still left the session's domains on disk.
crate::traffic_stats::delete_traffic_stats(&id);
}
let profiles_dir = self.get_profiles_dir();
@@ -1924,7 +1952,7 @@ pub async fn create_browser_profile_new(
.is_fingerprint_os_allowed(fingerprint_os)
.await
{
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
}
// A dead/unreachable proxy or VPN (or a 402 from an expired proxy
@@ -1968,7 +1996,7 @@ pub async fn update_wayfern_config(
.is_fingerprint_os_allowed(config.os.as_deref())
.await
{
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
}
let profile_manager = ProfileManager::instance();
+35
View File
@@ -103,6 +103,16 @@ pub fn get_host_os() -> String {
}
}
/// Whether a value is one `get_host_os` can actually return.
///
/// A fingerprint OS is a wider set than a host OS: `"android"` and `"ios"` are
/// valid fingerprints but no machine ever reports them as its host. Storing one
/// in `host_os` makes `is_cross_os` permanently true, which bars the profile
/// from every local launch path on the very machine that created it.
pub fn is_host_os(value: &str) -> bool {
matches!(value, "macos" | "windows" | "linux")
}
impl BrowserProfile {
/// Get the path to the profile data directory (profiles/{uuid}/profile)
pub fn get_profile_data_path(&self, profiles_dir: &Path) -> PathBuf {
@@ -138,3 +148,28 @@ impl BrowserProfile {
self.sync_mode == SyncMode::Encrypted
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_os_is_always_a_valid_host_os() {
// The invariant the host_os backfill guard rests on: whatever this machine
// reports must satisfy is_host_os, on every platform.
assert!(is_host_os(&get_host_os()));
}
#[test]
fn mobile_fingerprint_targets_are_not_host_operating_systems() {
// Backfilling host_os from a fingerprint OS used to store these, and since
// get_host_os can never return them, is_cross_os stayed true forever and
// the profile could not be launched on the machine that created it.
for os in ["macos", "windows", "linux"] {
assert!(is_host_os(os), "{os} must count as a host OS");
}
for os in ["android", "ios", "", "Windows", "chromeos"] {
assert!(!is_host_os(os), "{os} must not be stored as a host OS");
}
}
}
+551
View File
@@ -0,0 +1,551 @@
//! Copying a source profile into the new one.
//!
//! Two things a plain recursive copy gets wrong, both of which produce a
//! profile that looks imported and is not:
//!
//! - **Torn databases.** Users import from a browser they are still using. A
//! naive walk copies `Cookies` and `Cookies-wal` at different instants, and
//! Chromium's `sql::Database` razes the result on open. `VACUUM INTO` takes a
//! transactionally consistent snapshot instead, WAL content included, even
//! while the source holds the file.
//! - **Multi-GB of caches.** `Cache/`, `Code Cache/`, `GPUCache/` and friends
//! carry no user state and dominate both copy time and disk use.
use std::fs;
use std::path::Path;
/// Directories that never carry user state. Matched on the path relative to the
/// profile root, so `Service Worker/CacheStorage` is dropped while
/// `Service Worker/Database` survives.
const SKIP_DIRS: &[&str] = &[
"Cache",
"Code Cache",
"GPUCache",
"GrShaderCache",
"ShaderCache",
"DawnCache",
"DawnGraphiteCache",
"DawnWebGPUCache",
"GraphiteDawnCache",
"GPUPersistentCache",
"Service Worker/CacheStorage",
"Service Worker/ScriptCache",
"blob_storage",
"Crashpad",
"Crash Reports",
"BrowserMetrics",
"optimization_guide_model_store",
"optimization_guide_hint_cache_store",
"Safe Browsing",
"Safe Browsing Network",
"component_crx_cache",
"extensions_crx_cache",
"Download Service",
"Site Characteristics Database",
"shared_proto_db",
"segmentation_platform",
"Sync App Settings",
// SNSS command logs replay the source machine's windows and can embed
// absolute local paths in PageState blobs.
"Sessions",
"Session Storage",
];
/// Exact file names that are per-machine, per-run, or regenerated.
const SKIP_FILES: &[&str] = &[
"LOCK",
"LOG",
"LOG.old",
"SingletonLock",
"SingletonCookie",
"SingletonSocket",
"RunningChromeVersion",
"Last Version",
"first_party_sets.db",
".DS_Store",
"Thumbs.db",
// The account-bound part of `Sync Data/`. The rest of that directory is the
// local DataTypeStore — Reading List, Saved Tab Groups and friends, which
// exist for users who never signed in — so the folder itself is carried.
"Nigori.bin",
// Signed-in ephemeral twins of the real stores. They are wiped on sign-out,
// and the imported profile will not be signed in.
"Login Data For Account",
"Login Data For Account-journal",
"Account Web Data",
"Account Web Data-journal",
];
/// Suffixes that belong to a database we snapshot separately, or to scratch
/// state. Copying a `-wal` next to a vacuumed main file actively corrupts it.
const SKIP_SUFFIXES: &[&str] = &["-journal", "-wal", "-shm", ".tmp", ".old", ".bak.tmp"];
/// SQLite stores worth a consistent snapshot. Anything not listed is copied
/// byte-for-byte, which is correct for JSON, LevelDB and unpacked CRXs.
const SQLITE_FILES: &[&str] = &[
"Cookies",
"History",
"Favicons",
"Top Sites",
"Shortcuts",
"Login Data",
"Web Data",
"Affiliation Database",
"Network Action Predictor",
"DIPS",
"Trust Tokens",
"BudgetDatabase",
"AutofillStrikeDatabase",
"Reporting and NEL",
"SCT Auditing Pending Reports",
"Device Bound Sessions",
"MediaDeviceSalts",
"PreferredApps",
"heavy_ad_intervention_opt_out.db",
"SharedStorage",
"BrowsingTopicsSiteData",
"ClientCertificates",
"PersistentOriginTrials",
"Web Applications",
];
pub struct CopyOutcome {
pub bytes_copied: u64,
/// Names of stores that could not be snapshotted and were skipped rather
/// than copied in a corrupt state.
pub unreadable_stores: Vec<String>,
}
fn is_skipped_dir(relative: &Path) -> bool {
let normalized = relative.to_string_lossy().replace('\\', "/");
SKIP_DIRS.iter().any(|skip| {
normalized == *skip
|| normalized.ends_with(&format!("/{skip}"))
// `BrowserMetrics-spare.pma` and friends.
|| normalized.starts_with(&format!("{skip}-"))
})
}
fn is_skipped_file(name: &str) -> bool {
SKIP_FILES.contains(&name)
|| SKIP_SUFFIXES.iter().any(|suffix| name.ends_with(suffix))
|| name.starts_with("BrowserMetrics")
}
/// Copy the source's permission bits onto a file we produced ourselves.
///
/// `fs::copy` already preserves the mode, but `VACUUM INTO` lets SQLite create
/// the destination at its own default (0644). Cookies, Login Data and Web Data
/// are 0600 in both the source browser and Wayfern, and an import must not be
/// the step that widens them.
#[cfg(unix)]
fn mirror_mode(source: &Path, dest: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = fs::metadata(source) {
let mode = metadata.permissions().mode() & 0o777;
let _ = fs::set_permissions(dest, fs::Permissions::from_mode(mode));
}
}
#[cfg(not(unix))]
fn mirror_mode(_source: &Path, _dest: &Path) {}
/// Create a directory owner-only, matching what Chromium gives a profile.
fn create_private_dir(path: &Path) -> std::io::Result<()> {
fs::create_dir_all(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o700));
}
Ok(())
}
/// Take a consistent snapshot of a SQLite database.
///
/// Returns `Ok(false)` when the file is not actually SQLite (an empty
/// placeholder, say), so the caller can fall back to a plain copy.
fn vacuum_into(source: &Path, dest: &Path) -> Result<bool, String> {
use rusqlite::{Connection, OpenFlags};
let conn = match Connection::open_with_flags(
source,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
) {
Ok(conn) => conn,
Err(e) => return Err(format!("open failed: {e}")),
};
// Confirm it really is a database before trusting VACUUM's error reporting.
if conn
.query_row("SELECT count(*) FROM sqlite_master", [], |r| {
r.get::<_, i64>(0)
})
.is_err()
{
return Ok(false);
}
if dest.exists() {
fs::remove_file(dest).map_err(|e| format!("could not replace destination: {e}"))?;
}
// `VACUUM INTO` needs the path as a SQL string literal; single quotes are
// the only character that can break out of one.
let target = dest.to_string_lossy().replace('\'', "''");
conn
.execute_batch(&format!("VACUUM INTO '{target}'"))
.map_err(|e| format!("VACUUM INTO failed: {e}"))?;
mirror_mode(source, dest);
Ok(true)
}
/// Copy `source` (a Chromium profile directory) into `dest`, skipping caches
/// and snapshotting databases.
pub fn copy_profile_tree(source: &Path, dest: &Path) -> Result<CopyOutcome, String> {
let mut outcome = CopyOutcome {
bytes_copied: 0,
unreadable_stores: Vec::new(),
};
create_private_dir(dest).map_err(|e| format!("Failed to create {}: {e}", dest.display()))?;
copy_dir(source, dest, Path::new(""), &mut outcome)?;
Ok(outcome)
}
fn copy_dir(
source: &Path,
dest: &Path,
relative: &Path,
outcome: &mut CopyOutcome,
) -> Result<(), String> {
let entries =
fs::read_dir(source).map_err(|e| format!("Failed to read {}: {e}", source.display()))?;
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let child_relative = relative.join(name);
let source_path = entry.path();
let dest_path = dest.join(name);
// Symlinks are followed nowhere: Chromium writes them for the singleton
// lock, and a copied one would point at the source machine.
let metadata = match fs::symlink_metadata(&source_path) {
Ok(m) => m,
Err(_) => continue,
};
if metadata.file_type().is_symlink() {
continue;
}
if metadata.is_dir() {
if is_skipped_dir(&child_relative) {
continue;
}
create_private_dir(&dest_path)
.map_err(|e| format!("Failed to create {}: {e}", dest_path.display()))?;
copy_dir(&source_path, &dest_path, &child_relative, outcome)?;
continue;
}
if is_skipped_file(name) {
continue;
}
if SQLITE_FILES.contains(&name) {
match vacuum_into(&source_path, &dest_path) {
Ok(true) => {
outcome.bytes_copied += fs::metadata(&dest_path).map(|m| m.len()).unwrap_or(0);
continue;
}
Ok(false) => {
// Not a database after all; fall through to a byte copy.
}
Err(e) => {
// A store we cannot snapshot is a store we must not copy: a torn
// copy is deleted by Chromium on open, which looks identical to
// "the import silently lost my data".
log::warn!("Skipping unreadable store {}: {e}", source_path.display());
outcome.unreadable_stores.push(name.to_string());
continue;
}
}
}
match fs::copy(&source_path, &dest_path) {
Ok(bytes) => outcome.bytes_copied += bytes,
Err(e) => log::warn!("Failed to copy {}: {e}", source_path.display()),
}
}
Ok(())
}
/// Every `Default/`-level store that holds real user data, for reporting.
pub fn count_leveldb_origins(leveldb_dir: &Path) -> usize {
// Counting keys would mean linking a LevelDB implementation. The number of
// `.ldb`/`.log` segments is a stable proxy for "there is data here", which
// is all the report claims.
let Ok(entries) = fs::read_dir(leveldb_dir) else {
return 0;
};
entries
.flatten()
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.ends_with(".ldb") || n.ends_with(".log"))
})
.count()
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
use tempfile::TempDir;
fn touch(path: &Path, contents: &[u8]) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, contents).unwrap();
}
#[test]
fn caches_are_not_copied() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
touch(&source.join("Cache").join("data_0"), &[0u8; 4096]);
touch(
&source.join("Code Cache").join("js").join("x"),
&[0u8; 4096],
);
touch(
&source.join("Service Worker").join("CacheStorage").join("y"),
&[0u8; 4096],
);
touch(
&source
.join("Service Worker")
.join("Database")
.join("CURRENT"),
b"MANIFEST-000001\n",
);
copy_profile_tree(&source, &dest).unwrap();
assert!(dest.join("Preferences").exists());
assert!(!dest.join("Cache").exists());
assert!(!dest.join("Code Cache").exists());
assert!(!dest.join("Service Worker").join("CacheStorage").exists());
assert!(
dest.join("Service Worker").join("Database").exists(),
"the Service Worker registry is real data and must survive"
);
}
#[test]
fn lock_and_journal_files_are_not_copied() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
touch(
&source.join("Local Storage").join("leveldb").join("LOCK"),
b"",
);
touch(
&source.join("Local Storage").join("leveldb").join("CURRENT"),
b"MANIFEST-000001\n",
);
touch(&source.join("History-journal"), b"junk");
copy_profile_tree(&source, &dest).unwrap();
assert!(!dest
.join("Local Storage")
.join("leveldb")
.join("LOCK")
.exists());
assert!(dest
.join("Local Storage")
.join("leveldb")
.join("CURRENT")
.exists());
assert!(!dest.join("History-journal").exists());
}
#[test]
fn sqlite_stores_are_snapshotted_and_stay_queryable() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
fs::create_dir_all(&source).unwrap();
touch(&source.join("Preferences"), b"{}");
let db = source.join("History");
let conn = Connection::open(&db).unwrap();
conn
.execute_batch("CREATE TABLE urls(id INTEGER PRIMARY KEY, url TEXT); INSERT INTO urls(url) VALUES('https://example.com');")
.unwrap();
drop(conn);
copy_profile_tree(&source, &dest).unwrap();
let copied = Connection::open(dest.join("History")).unwrap();
let count: i64 = copied
.query_row("SELECT count(*) FROM urls", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn snapshot_captures_uncheckpointed_wal_content() {
// The whole reason for VACUUM INTO: a running browser leaves recent writes
// in the WAL, and a plain file copy loses them.
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
fs::create_dir_all(&source).unwrap();
touch(&source.join("Preferences"), b"{}");
let db = source.join("History");
let conn = Connection::open(&db).unwrap();
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
conn
.execute_batch("CREATE TABLE urls(id INTEGER PRIMARY KEY, url TEXT);")
.unwrap();
conn
.execute("INSERT INTO urls(url) VALUES('https://in-wal.example')", [])
.unwrap();
// Deliberately do not checkpoint or close: this is the live-browser shape.
copy_profile_tree(&source, &dest).unwrap();
drop(conn);
let copied = Connection::open(dest.join("History")).unwrap();
let url: String = copied
.query_row("SELECT url FROM urls", [], |r| r.get(0))
.unwrap();
assert_eq!(url, "https://in-wal.example");
assert!(
!dest.join("History-wal").exists(),
"a stale -wal beside a vacuumed file corrupts it"
);
}
#[test]
fn symlinks_are_never_followed() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
let outside = dir.path().join("outside.txt");
touch(&outside, b"secret");
#[cfg(unix)]
std::os::unix::fs::symlink(&outside, source.join("SingletonLock")).unwrap();
copy_profile_tree(&source, &dest).unwrap();
assert!(!dest.join("SingletonLock").exists());
}
#[test]
fn account_scoped_stores_are_dropped() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
touch(&source.join("Login Data For Account"), b"x");
touch(&source.join("Sync Data").join("Nigori.bin"), b"x");
touch(
&source.join("Sync Data").join("LevelDB").join("CURRENT"),
b"x",
);
copy_profile_tree(&source, &dest).unwrap();
assert!(!dest.join("Login Data For Account").exists());
assert!(
!dest.join("Sync Data").join("Nigori.bin").exists(),
"the Nigori keyset is bound to a Google account"
);
assert!(
dest
.join("Sync Data")
.join("LevelDB")
.join("CURRENT")
.exists(),
"the rest of Sync Data is local state such as the reading list"
);
}
#[test]
#[cfg(unix)]
fn copied_databases_keep_the_browsers_private_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
fs::create_dir_all(&source).unwrap();
touch(&source.join("Preferences"), b"{}");
let db = source.join("Cookies");
let conn = rusqlite::Connection::open(&db).unwrap();
conn
.execute_batch("CREATE TABLE cookies(x INTEGER);")
.unwrap();
drop(conn);
fs::set_permissions(&db, fs::Permissions::from_mode(0o600)).unwrap();
copy_profile_tree(&source, &dest).unwrap();
// VACUUM INTO would otherwise create the snapshot at SQLite's default 0644.
let mode = fs::metadata(dest.join("Cookies"))
.unwrap()
.permissions()
.mode();
assert_eq!(
mode & 0o777,
0o600,
"an import must not widen a cookie store"
);
let dir_mode = fs::metadata(&dest).unwrap().permissions().mode();
assert_eq!(dir_mode & 0o777, 0o700);
}
#[test]
fn unreadable_store_is_reported_not_copied_corrupt() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
// A file that opens as SQLite but is structurally broken.
touch(
&source.join("Cookies"),
b"SQLite format 3\0garbage-not-a-db",
);
let outcome = copy_profile_tree(&source, &dest).unwrap();
assert!(
!dest.join("Cookies").exists() || outcome.unreadable_stores.is_empty(),
"a store is either snapshotted cleanly or skipped and reported"
);
}
#[test]
fn non_sqlite_file_with_a_store_name_still_copies() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
touch(&source.join("Top Sites"), b"");
copy_profile_tree(&source, &dest).unwrap();
assert!(dest.join("Top Sites").exists());
}
}
+398
View File
@@ -0,0 +1,398 @@
//! Recovering the *source* browser's os_crypt key.
//!
//! Every Chromium-family browser seals cookies, passwords and payment data with
//! a key held outside the profile: the macOS Keychain, a DPAPI blob in
//! `Local State`, or the Freedesktop secret service. Import has to open that
//! lock before it can re-seal anything with Wayfern's portable key
//! ([`super::os_crypt::TargetKey`]).
//!
//! Failure here is never fatal. A declined Keychain prompt or a locked keyring
//! degrades to "everything except the secrets came across", recorded as a
//! warning, because a partial profile is worth far more than a failed import.
#[cfg(target_os = "windows")]
use super::os_crypt::CryptoKey;
use super::os_crypt::SourceKeyring;
#[cfg(target_os = "macos")]
use super::os_crypt::MAC_ITERATIONS;
#[cfg(any(target_os = "macos", target_os = "linux"))]
use super::os_crypt::{derive_key, CryptoKey};
#[cfg(target_os = "linux")]
use super::os_crypt::{POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS};
use super::report::warning;
use std::path::Path;
/// Keychain / secret-service identities to try for a source family, most
/// specific first.
///
/// Trying several is safe and costs nothing: a lookup for a service that does
/// not exist fails without prompting, so at most one dialog appears — the one
/// for the item that is actually there. That is what lets a single `chromium`
/// family key cover both Google Chrome and vanilla Chromium, which share a
/// detection entry but not a Keychain item.
// Consulted by the Keychain and secret-service lookups. Windows resolves the
// key through DPAPI against the profile's own Local State, so it never needs
// to guess a brand.
#[allow(dead_code)]
fn brand_candidates(family: &str, source_path: &Path) -> Vec<&'static str> {
let path = source_path.to_string_lossy();
let mut brands: Vec<&'static str> = match family {
"chrome-beta" => vec!["Chrome Beta", "Chrome"],
"chrome-dev" => vec!["Chrome Dev", "Chrome"],
"chrome-canary" => vec!["Chrome Canary", "Chrome"],
"brave" => vec!["Brave", "Brave Browser"],
"brave-beta" => vec!["Brave Beta", "Brave Browser", "Brave"],
"brave-nightly" => vec!["Brave Nightly", "Brave Browser", "Brave"],
"edge" => vec!["Microsoft Edge", "Chromium"],
"edge-beta" => vec!["Microsoft Edge Beta", "Microsoft Edge"],
"edge-dev" => vec!["Microsoft Edge Dev", "Microsoft Edge"],
"vivaldi" => vec!["Vivaldi", "Chromium"],
"opera" => vec!["Opera", "Chromium"],
"opera-gx" => vec!["Opera GX", "Opera", "Chromium"],
"arc" => vec!["Arc", "Chromium"],
"yandex" => vec!["Yandex", "Yandex Browser", "Chromium"],
// "chromium" covers both Google Chrome and upstream Chromium; the install
// path is the only thing that tells them apart.
_ => vec!["Chrome", "Chromium"],
};
if (family.is_empty() || family == "chromium")
&& path.contains("Chromium")
&& !path.contains("Google")
{
brands = vec!["Chromium", "Chrome"];
}
brands
}
/// Recover whatever key material the source browser used.
///
/// `source_user_data_dir` is the directory holding `Local State` (the parent of
/// the profile directory), which is where Windows keeps its wrapped key. It is
/// `None` when the user pointed at a bare profile folder with no parent we can
/// trust.
pub fn recover_source_keys(
family: &str,
source_path: &Path,
source_user_data_dir: Option<&Path>,
report: &mut super::report::ProfileImportReport,
) -> SourceKeyring {
let mut keyring = SourceKeyring::default();
#[cfg(target_os = "macos")]
{
let _ = source_user_data_dir;
for brand in brand_candidates(family, source_path) {
match macos_keychain_password(brand) {
Ok(Some(password)) => {
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(&password, MAC_ITERATIONS)));
log::info!("Recovered os_crypt password for '{brand} Safe Storage'");
break;
}
Ok(None) => continue,
Err(e) => {
log::warn!("Keychain lookup for '{brand} Safe Storage' failed: {e}");
break;
}
}
}
}
#[cfg(target_os = "windows")]
{
let _ = source_path;
if let Some(dir) = source_user_data_dir {
match windows_local_state_key(dir) {
Ok(Some(key)) => keyring.v10 = Some(CryptoKey::Aes256Gcm(key)),
Ok(None) => {}
Err(e) => log::warn!("DPAPI key recovery failed: {e}"),
}
if windows_has_app_bound_key(dir) {
// Recorded up front: the cookie store will be full of `v20` records
// and the user deserves to know why before they see the count.
report.warn(warning::APP_BOUND_ENCRYPTED);
}
}
}
#[cfg(target_os = "linux")]
{
let _ = source_user_data_dir;
// A profile can hold both tags at once, so populate both slots rather than
// choosing one. v10 is always available: it is a hardcoded password.
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(
POSIX_FALLBACK_PASSWORD,
POSIX_ITERATIONS,
)));
for brand in brand_candidates(family, source_path) {
match linux_secret_service_password(brand) {
Ok(Some(password)) => {
keyring.v11 = Some(CryptoKey::Aes128Cbc(derive_key(
&password,
POSIX_ITERATIONS,
)));
log::info!("Recovered os_crypt secret for '{brand} Safe Storage'");
break;
}
Ok(None) => continue,
Err(e) => {
log::warn!("Secret service lookup for '{brand} Safe Storage' failed: {e}");
break;
}
}
}
}
if keyring.is_empty() {
report.warn(warning::SECRETS_NOT_MIGRATED);
}
// Silence unused-parameter warnings on platforms that do not use every arg.
let _ = (family, source_path, source_user_data_dir);
keyring
}
/// How long to wait on a keyring before giving up.
///
/// Both backends can put a dialog in front of the user — macOS asks whether
/// Donut may read another app's Keychain item, and an unlocked-on-demand
/// keyring prompts on Linux. That is fine interactively, but an import driven
/// over REST or MCP would otherwise wedge forever with nobody at the screen.
/// Long enough for a person to notice and click; short enough that automation
/// recovers into "secrets not migrated", which is merely a partial import.
#[cfg(any(target_os = "macos", target_os = "linux"))]
const KEYRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
/// Run a keyring lookup on its own OS thread, bounded by [`KEYRING_TIMEOUT`].
///
/// Off-thread rather than inline for two reasons: import already runs inside
/// `spawn_blocking`, and zbus's blocking API drives a private tokio runtime, so
/// keeping it off a runtime-owned thread sidesteps any nested-runtime question;
/// and it turns a panic or a stuck IPC call into a recoverable warning instead
/// of a failed import.
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn run_keyring_lookup<F>(what: &str, lookup: F) -> Result<Option<Vec<u8>>, String>
where
F: FnOnce() -> Result<Option<Vec<u8>>, String> + Send + std::panic::UnwindSafe + 'static,
{
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let result =
std::panic::catch_unwind(lookup).unwrap_or_else(|_| Err("lookup panicked".to_string()));
let _ = tx.send(result);
});
match rx.recv_timeout(KEYRING_TIMEOUT) {
Ok(result) => result,
Err(_) => Err(format!("{what} did not respond")),
}
}
#[cfg(target_os = "macos")]
fn macos_keychain_password(brand: &str) -> Result<Option<Vec<u8>>, String> {
let brand = brand.to_string();
run_keyring_lookup("keychain", move || macos_keychain_lookup(&brand))
}
#[cfg(target_os = "macos")]
fn macos_keychain_lookup(brand: &str) -> Result<Option<Vec<u8>>, String> {
use security_framework::passwords::get_generic_password;
let service = format!("{brand} Safe Storage");
match get_generic_password(&service, brand) {
Ok(password) => Ok(Some(password)),
Err(e) => {
// errSecItemNotFound: this brand simply is not installed. Anything else
// (notably errSecAuthFailed / errSecUserCanceled when the user declines
// the access dialog) is a real failure worth surfacing.
if e.code() == -25300 {
Ok(None)
} else {
Err(e.to_string())
}
}
}
}
#[cfg(target_os = "windows")]
fn read_local_state_os_crypt(dir: &Path) -> Option<serde_json::Value> {
let raw = std::fs::read_to_string(dir.join("Local State")).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
parsed.get("os_crypt").cloned()
}
#[cfg(target_os = "windows")]
fn windows_has_app_bound_key(dir: &Path) -> bool {
read_local_state_os_crypt(dir)
.and_then(|v| {
v.get("app_bound_encrypted_key")
.and_then(|k| k.as_str().map(str::to_string))
})
.is_some_and(|k| !k.is_empty())
}
#[cfg(target_os = "windows")]
fn windows_local_state_key(dir: &Path) -> Result<Option<[u8; 32]>, String> {
use base64::Engine;
let Some(os_crypt) = read_local_state_os_crypt(dir) else {
return Ok(None);
};
let Some(encoded) = os_crypt.get("encrypted_key").and_then(|k| k.as_str()) else {
return Ok(None);
};
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|e| format!("encrypted_key is not valid base64: {e}"))?;
// The blob is "DPAPI" || CryptProtectData(key).
const DPAPI_PREFIX: &[u8] = b"DPAPI";
if !decoded.starts_with(DPAPI_PREFIX) {
return Err("encrypted_key is missing the DPAPI header".to_string());
}
let unwrapped = dpapi_unprotect(&decoded[DPAPI_PREFIX.len()..])?;
let key: [u8; 32] = unwrapped
.as_slice()
.try_into()
.map_err(|_| format!("expected a 32-byte AES key, got {} bytes", unwrapped.len()))?;
Ok(Some(key))
}
#[cfg(target_os = "windows")]
fn dpapi_unprotect(ciphertext: &[u8]) -> Result<Vec<u8>, String> {
use windows::Win32::Foundation::LocalFree;
use windows::Win32::Security::Cryptography::{CryptUnprotectData, CRYPT_INTEGER_BLOB};
// `pdatain` is `*const CRYPT_INTEGER_BLOB`: DPAPI only reads the input blob,
// so a shared reference is what the signature wants.
let input = CRYPT_INTEGER_BLOB {
cbData: ciphertext.len() as u32,
pbData: ciphertext.as_ptr() as *mut u8,
};
let mut output = CRYPT_INTEGER_BLOB::default();
// SAFETY: `input` points at a live slice for the duration of the call, and
// `output` is freed via LocalFree exactly once below, as the API requires.
unsafe {
CryptUnprotectData(&input, None, None, None, None, 0, &mut output)
.map_err(|e| format!("CryptUnprotectData failed: {e}"))?;
let plaintext = std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec();
let _ = LocalFree(Some(windows::Win32::Foundation::HLOCAL(
output.pbData as *mut core::ffi::c_void,
)));
Ok(plaintext)
}
}
#[cfg(target_os = "linux")]
fn linux_secret_service_password(brand: &str) -> Result<Option<Vec<u8>>, String> {
let brand = brand.to_string();
run_keyring_lookup("secret service", move || {
linux_secret_service_lookup(&brand)
})
}
#[cfg(target_os = "linux")]
fn linux_secret_service_lookup(brand: &str) -> Result<Option<Vec<u8>>, String> {
use secret_service::blocking::SecretService;
use secret_service::EncryptionType;
use std::collections::HashMap;
let service =
SecretService::connect(EncryptionType::Dh).map_err(|e| format!("no secret service: {e}"))?;
let collection = service
.get_default_collection()
.map_err(|e| format!("no default collection: {e}"))?;
if collection.is_locked().unwrap_or(true) {
collection
.unlock()
.map_err(|e| format!("keyring is locked: {e}"))?;
}
// Match on the item's LABEL, not on its `application` attribute.
//
// `freedesktop_secret_key_provider.cc` stores two attributes —
// `application: kAppName` and `xdg:schema` — and sets the label to
// `kKeyName`, which is always "<Brand> Safe Storage". `kAppName` is a
// per-fork branding string ("chrome", "chromium", …) that we cannot derive
// from a display name: lowercasing "Microsoft Edge" gives "microsoft edge",
// which matches nothing, and the search would silently return zero items.
// The label is the one identifier that is the same across every fork and is
// exactly the string we already build for the macOS Keychain.
let label = format!("{brand} Safe Storage");
// The schema attribute narrows the scan to os_crypt secrets; it is shared by
// every Chromium fork, so it costs nothing in portability.
let mut attributes = HashMap::new();
attributes.insert("xdg:schema", "chrome_libsecret_os_crypt_password_v2");
let mut items = collection
.search_items(attributes)
.map_err(|e| format!("search failed: {e}"))?;
if items.is_empty() {
// Older Chromium releases used a v1 schema, and some forks omit it.
items = collection
.get_all_items()
.map_err(|e| format!("could not list items: {e}"))?;
}
for item in &items {
if item.get_label().is_ok_and(|found| found == label) {
return item
.get_secret()
.map(Some)
.map_err(|e| format!("could not read secret: {e}"));
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn chromium_family_disambiguates_chrome_from_chromium_by_path() {
let chrome = PathBuf::from("/Users/x/Library/Application Support/Google/Chrome/Default");
assert_eq!(brand_candidates("chromium", &chrome)[0], "Chrome");
let chromium = PathBuf::from("/Users/x/Library/Application Support/Chromium/Default");
assert_eq!(brand_candidates("chromium", &chromium)[0], "Chromium");
}
#[test]
fn every_brand_falls_back_to_a_second_candidate() {
// A single candidate means one wrong guess loses the secrets entirely, so
// each family must offer a fallback identity.
for family in [
"chrome-beta",
"chrome-dev",
"chrome-canary",
"brave",
"edge",
"vivaldi",
"opera",
"opera-gx",
"arc",
"yandex",
"chromium",
] {
let candidates = brand_candidates(family, Path::new("/tmp/profile"));
assert!(
candidates.len() >= 2,
"{family} needs a fallback brand candidate"
);
}
}
#[test]
fn unknown_family_still_yields_candidates() {
let candidates = brand_candidates("something-new", Path::new("/tmp/profile"));
assert!(!candidates.is_empty());
}
}
+380
View File
@@ -0,0 +1,380 @@
//! Working out what the user pointed at, and where its files have to land.
//!
//! Two layout facts drive everything here:
//!
//! 1. Donut launches with `--user-data-dir` and no `--profile-directory`, so
//! Chromium reads `<user-data-dir>/Default/` (`chrome_constants.cc`
//! `kInitialProfile`). A source *profile* directory therefore has to be
//! copied one level down, not onto the root.
//! 2. Network state (`Cookies`, `TransportSecurity`, …) lives in
//! `Default/Network/` on Windows and in `Default/` everywhere else. That
//! split is not cosmetic: `kTriggerNetworkDataMigration` is enabled by
//! default only on Windows, and on the other platforms Chromium actively
//! redirects reads back to `Default/`. A profile exported from Windows is
//! invisible on macOS until its files are moved up, and vice versa.
use std::path::{Path, PathBuf};
/// Files Chromium keeps under `Default/Network/` on Windows and directly under
/// `Default/` on macOS and Linux.
pub const NETWORK_DATA_FILES: &[&str] = &[
"Cookies",
"Cookies-journal",
"Network Persistent State",
"Reporting and NEL",
"SCT Auditing Pending Reports",
"Trust Tokens",
"Trust Tokens-journal",
"TransportSecurity",
"Device Bound Sessions",
"Device Bound Sessions-journal",
];
/// What the user handed us.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SourceKind {
/// A profile directory (holds `Preferences`): `.../Chrome/Default`.
ProfileDir,
/// A user-data directory whose profile lives at its root — Opera's layout.
RootProfileUserDataDir,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceShape {
pub kind: SourceKind,
/// The directory holding `Preferences` — the content that becomes `Default/`.
pub profile_dir: PathBuf,
/// The directory holding `Local State`, when there is one. Windows keeps the
/// DPAPI-wrapped os_crypt key there, so losing it loses every secret.
pub user_data_dir: Option<PathBuf>,
}
/// Why a directory cannot be imported.
#[derive(Debug, PartialEq, Eq)]
pub enum RejectReason {
/// Recognisably a Gecko profile. Worth naming explicitly: silently returning
/// "nothing found" for a Firefox folder is what made import feel broken.
Firefox,
/// Not a browser profile we recognise at all.
NotChromium,
}
/// Markers that identify a real Chromium profile directory. `Preferences` is
/// the usual one, but a profile whose prefs were wiped still has data worth
/// carrying, so any of these counts.
const CHROMIUM_PROFILE_MARKERS: &[&str] = &[
"Preferences",
"Secure Preferences",
"History",
"Cookies",
"Bookmarks",
"Web Data",
"Login Data",
];
fn looks_like_chromium_profile(dir: &Path) -> bool {
CHROMIUM_PROFILE_MARKERS
.iter()
.any(|marker| dir.join(marker).exists())
// Windows-layout profiles keep Cookies one level down.
|| dir.join("Network").join("Cookies").exists()
}
fn looks_like_firefox_profile(dir: &Path) -> bool {
// Any one of these alone can appear elsewhere; together they are conclusive.
let markers = ["prefs.js", "places.sqlite", "cookies.sqlite", "key4.db"];
markers.iter().filter(|m| dir.join(m).exists()).count() >= 2
}
/// Classify an import source, or explain why it cannot be one.
pub fn classify(source: &Path) -> Result<SourceShape, RejectReason> {
if looks_like_firefox_profile(source) {
return Err(RejectReason::Firefox);
}
if !looks_like_chromium_profile(source) {
return Err(RejectReason::NotChromium);
}
// A directory that holds both profile markers and `Local State` is Opera's
// root-profile layout: the user-data dir and the profile are the same place.
let kind = if source.join("Local State").exists() {
SourceKind::RootProfileUserDataDir
} else {
SourceKind::ProfileDir
};
let user_data_dir = match kind {
SourceKind::RootProfileUserDataDir => Some(source.to_path_buf()),
// For `.../Chrome/Default`, `Local State` is in `.../Chrome`. Only accept
// the parent if it really holds one, so a profile copied to a random
// folder does not make us read a stranger's `Local State`.
SourceKind::ProfileDir => source.parent().and_then(|parent| {
if parent.join("Local State").exists() {
return Some(parent.to_path_buf());
}
// Opera keeps its extra profiles at `<user-data-dir>/_side_profiles/<id>`
// but still launches them against the same user-data dir, so the
// DPAPI-wrapped os_crypt key sits one further level up. Without this,
// every Opera side profile imports on Windows with no secrets at all.
if parent.file_name() == Some(std::ffi::OsStr::new("_side_profiles")) {
return parent
.parent()
.filter(|root| root.join("Local State").exists())
.map(Path::to_path_buf);
}
None
}),
};
Ok(SourceShape {
kind,
profile_dir: source.to_path_buf(),
user_data_dir,
})
}
/// Move network data into the position the *host* Chromium build reads from.
///
/// Host, not source: the files were written by whatever browser produced them,
/// but they will be read by Wayfern running here. Getting this backwards is a
/// silent, total cookie loss on any cross-platform import.
pub fn normalize_network_dir(default_dir: &Path) -> std::io::Result<()> {
let network_dir = default_dir.join("Network");
let (from, to) = if cfg!(target_os = "windows") {
(default_dir.to_path_buf(), network_dir.clone())
} else {
(network_dir.clone(), default_dir.to_path_buf())
};
if !from.exists() {
return Ok(());
}
for name in NETWORK_DATA_FILES {
let src = from.join(name);
if !src.is_file() {
continue;
}
std::fs::create_dir_all(&to)?;
let dest = to.join(name);
if dest.exists() {
// Both positions hold the file. The one in the source position is the
// stale duplicate: on Windows, Chromium's migration would copy it over
// the newer file ("overwrite the new file with the old file even if it
// exists already", network_sandbox.cc), so it has to go.
std::fs::remove_file(&src)?;
continue;
}
std::fs::rename(&src, &dest).or_else(|_| {
// Rename across devices can fail even within one tree on some setups.
std::fs::copy(&src, &dest).and_then(|_| std::fs::remove_file(&src))?;
Ok::<(), std::io::Error>(())
})?;
}
if !cfg!(target_os = "windows") {
// Chromium's migration checkpoint, and the reason an otherwise-correct
// move is not enough. `network_sandbox.cc:478` treats the presence of
// `NetworkDataMigrated` as proof the migration already ran, keeps the (now
// empty) `Network/` as the data directory, and then `CleanUpOldData` at
// `:536-540` DELETES the files we just moved up into `Default/`. A profile
// exported from Windows would lose every cookie on first launch.
let _ = std::fs::remove_file(network_dir.join("NetworkDataMigrated"));
// Leave no empty `Network/` behind: harmless, but it makes a profile look
// like it still holds network state.
if network_dir.is_dir() && std::fs::read_dir(&network_dir)?.next().is_none() {
let _ = std::fs::remove_dir(&network_dir);
}
}
Ok(())
}
/// Where the cookie store ends up for the host platform.
pub fn host_cookie_path(default_dir: &Path) -> PathBuf {
if cfg!(target_os = "windows") {
default_dir.join("Network").join("Cookies")
} else {
default_dir.join("Cookies")
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn touch(path: &Path) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, b"x").unwrap();
}
#[test]
fn plain_profile_dir_is_classified_without_a_user_data_dir() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("Default");
touch(&profile.join("Preferences"));
let shape = classify(&profile).expect("should classify");
assert_eq!(shape.kind, SourceKind::ProfileDir);
assert_eq!(shape.user_data_dir, None);
}
#[test]
fn profile_dir_finds_local_state_in_its_parent() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("Default");
touch(&profile.join("Preferences"));
touch(&dir.path().join("Local State"));
let shape = classify(&profile).expect("should classify");
// Windows keeps the wrapped os_crypt key here; missing it means no secrets.
assert_eq!(shape.user_data_dir.as_deref(), Some(dir.path()));
}
#[test]
fn opera_root_layout_is_its_own_user_data_dir() {
let dir = TempDir::new().unwrap();
touch(&dir.path().join("Preferences"));
touch(&dir.path().join("Local State"));
let shape = classify(dir.path()).expect("should classify");
assert_eq!(shape.kind, SourceKind::RootProfileUserDataDir);
assert_eq!(shape.user_data_dir.as_deref(), Some(dir.path()));
}
#[test]
fn firefox_profile_is_rejected_by_name() {
let dir = TempDir::new().unwrap();
touch(&dir.path().join("prefs.js"));
touch(&dir.path().join("places.sqlite"));
assert_eq!(classify(dir.path()), Err(RejectReason::Firefox));
}
#[test]
fn empty_directory_is_rejected() {
let dir = TempDir::new().unwrap();
assert_eq!(classify(dir.path()), Err(RejectReason::NotChromium));
}
#[test]
fn windows_layout_profile_is_recognised_without_root_markers() {
// A profile whose only surviving data is Windows-layout cookies.
let dir = TempDir::new().unwrap();
touch(&dir.path().join("Network").join("Cookies"));
assert!(classify(dir.path()).is_ok());
}
#[test]
fn opera_side_profile_finds_local_state_two_levels_up() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("_side_profiles").join("gaming");
touch(&profile.join("Preferences"));
touch(&dir.path().join("Local State"));
let shape = classify(&profile).expect("should classify");
assert_eq!(
shape.user_data_dir.as_deref(),
Some(dir.path()),
"Windows keeps the os_crypt key in the root Local State, not beside the profile"
);
}
#[test]
fn a_profile_in_an_unrelated_folder_does_not_adopt_a_strangers_local_state() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("_side_profiles").join("gaming");
touch(&profile.join("Preferences"));
// No Local State anywhere above it.
let shape = classify(&profile).expect("should classify");
assert_eq!(shape.user_data_dir, None);
}
#[test]
fn migration_checkpoint_is_removed_so_chromium_does_not_delete_the_moved_files() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
touch(&default_dir.join("Network").join("Cookies"));
touch(&default_dir.join("Network").join("NetworkDataMigrated"));
normalize_network_dir(&default_dir).unwrap();
assert!(host_cookie_path(&default_dir).is_file());
if !cfg!(target_os = "windows") {
assert!(
!default_dir
.join("Network")
.join("NetworkDataMigrated")
.exists(),
"the checkpoint makes Chromium delete the files we just moved up"
);
assert!(!default_dir.join("Network").exists());
}
}
#[test]
fn network_files_are_moved_into_the_host_position() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
// Seed the file in the position the host does NOT read from.
if cfg!(target_os = "windows") {
touch(&default_dir.join("Cookies"));
} else {
touch(&default_dir.join("Network").join("Cookies"));
}
normalize_network_dir(&default_dir).unwrap();
assert!(
host_cookie_path(&default_dir).is_file(),
"cookies must end up where this platform's Chromium reads them"
);
}
#[test]
fn stale_duplicate_in_the_source_position_is_removed() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
touch(&default_dir.join("Cookies"));
touch(&default_dir.join("Network").join("Cookies"));
normalize_network_dir(&default_dir).unwrap();
assert!(host_cookie_path(&default_dir).is_file());
let stale = if cfg!(target_os = "windows") {
default_dir.join("Cookies")
} else {
default_dir.join("Network").join("Cookies")
};
assert!(
!stale.exists(),
"the duplicate would be copied over the live file by Chromium's migration"
);
}
#[test]
fn normalize_is_idempotent() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
touch(&default_dir.join("Network").join("Cookies"));
normalize_network_dir(&default_dir).unwrap();
normalize_network_dir(&default_dir).unwrap();
assert!(host_cookie_path(&default_dir).is_file());
}
#[test]
fn normalize_on_a_profile_with_no_network_data_is_a_no_op() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
std::fs::create_dir_all(&default_dir).unwrap();
normalize_network_dir(&default_dir).unwrap();
assert!(!host_cookie_path(&default_dir).exists());
}
}
+372
View File
@@ -0,0 +1,372 @@
//! Turning someone else's browser profile into one Wayfern will actually load.
//!
//! The old importer copied a source profile directory verbatim onto the new
//! profile's `--user-data-dir`. Chromium reads `<user-data-dir>/Default/`, so
//! every imported file sat one level above where the browser looked and the
//! profile came up empty — and even in the right place the secrets would not
//! have opened, because they are sealed with a key held in the source
//! machine's Keychain / DPAPI / secret service that Wayfern never consults.
//!
//! This module does the whole job: classify the source, recover its key, copy
//! with consistent database snapshots, put the files where Chromium reads them,
//! re-seal every secret with Wayfern's portable key, and report exactly what
//! came across.
pub mod copy;
pub mod keyring;
pub mod layout;
pub mod os_crypt;
pub mod report;
pub mod rewrite;
use layout::RejectReason;
use report::{warning, ProfileImportReport};
use std::path::Path;
/// The profile subdirectory Chromium reads when no `--profile-directory` is
/// passed (`chrome_constants.cc` `kInitialProfile`). Donut never passes one.
pub const INITIAL_PROFILE_DIR: &str = "Default";
/// Import `source` into `dest_user_data_dir`, which becomes the new profile's
/// `--user-data-dir`.
///
/// Never fails because part of the data could not be carried: partial results
/// plus an honest report beat an all-or-nothing import that leaves the user
/// with nothing and no explanation. It fails only when the source is not
/// importable at all, or when the target key cannot be established — without
/// that key, anything written would be unreadable forever.
pub fn import_into(
source: &Path,
dest_user_data_dir: &Path,
source_family: &str,
allow_running: bool,
) -> Result<ProfileImportReport, String> {
let shape = layout::classify(source).map_err(|reason| match reason {
RejectReason::Firefox => serde_json::json!({
"code": "IMPORT_SOURCE_NOT_CHROMIUM",
"params": { "family": "Firefox" }
})
.to_string(),
RejectReason::NotChromium => serde_json::json!({
"code": "IMPORT_SOURCE_NOT_CHROMIUM",
"params": { "family": "" }
})
.to_string(),
})?;
let mut report = ProfileImportReport::default();
if let Some(running) = running_source_browser(&shape) {
if !allow_running {
return Err(
serde_json::json!({
"code": "IMPORT_SOURCE_BROWSER_RUNNING",
"params": { "browser": running }
})
.to_string(),
);
}
// Databases are snapshotted transactionally, but LevelDB site data is
// copied as files and can be mid-write.
report.warn(warning::SOURCE_BROWSER_RUNNING);
}
// Mint the target key first. Everything after this point is written to be
// readable with it, and a profile whose key could not be persisted would
// lose every secret the first time the browser exits.
let target = os_crypt::TargetKey::ensure(dest_user_data_dir)?;
// Recover the source key before the copy: on macOS this may prompt, and
// asking before a multi-GB copy respects the user's time.
let source_keys = keyring::recover_source_keys(
source_family,
&shape.profile_dir,
shape.user_data_dir.as_deref(),
&mut report,
);
let default_dir = dest_user_data_dir.join(INITIAL_PROFILE_DIR);
let outcome = copy::copy_profile_tree(&shape.profile_dir, &default_dir)?;
report.bytes_copied = outcome.bytes_copied;
if !outcome.unreadable_stores.is_empty() {
report.warn(warning::STORE_UNREADABLE);
}
layout::normalize_network_dir(&default_dir)
.map_err(|e| format!("Failed to place network data: {e}"))?;
rewrite::finalize_profile(&default_dir, &source_keys, &target, &mut report);
Ok(report)
}
/// Is the browser that owns this profile currently running?
///
/// Matched on the profile path in the process command line rather than on the
/// executable name: the user may well have Chrome open on a *different*
/// profile, which is no reason to block the import.
fn running_source_browser(shape: &layout::SourceShape) -> Option<String> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let needle = shape
.user_data_dir
.as_deref()
.unwrap_or(&shape.profile_dir)
.to_string_lossy()
.to_string();
if needle.is_empty() {
return None;
}
for process in system.processes().values() {
let name = process.name().to_string_lossy().to_lowercase();
let looks_like_a_browser = name.contains("chrome")
|| name.contains("chromium")
|| name.contains("brave")
|| name.contains("edge")
|| name.contains("vivaldi")
|| name.contains("opera")
|| name.contains("arc")
|| name.contains("yandex");
if !looks_like_a_browser {
continue;
}
// Donut's own browser is Wayfern; never report it as the source.
if name.contains("wayfern") {
continue;
}
if process
.cmd()
.iter()
.any(|arg| arg.to_string_lossy().contains(&needle))
{
return Some(process.name().to_string_lossy().to_string());
}
}
None
}
/// Move a profile that an earlier build imported into the broken root layout
/// down into `Default/`, where the browser reads it.
///
/// Without this, everything those users imported stays stranded: their real
/// data sits at `profile/Cookies` while Wayfern reads and writes
/// `profile/Default/Cookies`. Their secrets remain unreadable — the source key
/// was never captured and cannot be recovered after the fact — but history,
/// bookmarks, extensions and site data become visible again.
///
/// Returns `Ok(true)` when a repair was performed.
pub fn repair_legacy_layout(user_data_dir: &Path) -> Result<bool, String> {
let default_dir = user_data_dir.join(INITIAL_PROFILE_DIR);
// The broken shape is exactly: profile markers at the root, and no `Default/`
// for the browser to have used instead.
let has_root_profile = user_data_dir.join("Preferences").exists()
|| user_data_dir.join("History").exists()
|| user_data_dir.join("Cookies").exists();
if !has_root_profile || default_dir.exists() {
return Ok(false);
}
// Root-level files that belong to the user-data dir, not to the profile.
const ROOT_LEVEL: &[&str] = &[
"Local State",
"os_crypt_key",
"First Run",
"Last Version",
"Variations",
"ChromeFeatureState",
"RunningChromeVersion",
"SingletonLock",
"SingletonCookie",
"SingletonSocket",
"user.js",
"metadata.json",
".donut-sync",
];
let staging = user_data_dir.join(".donut-import-repair");
if staging.exists() {
std::fs::remove_dir_all(&staging).map_err(|e| format!("Failed to clear staging: {e}"))?;
}
std::fs::create_dir_all(&staging).map_err(|e| format!("Failed to create staging: {e}"))?;
let entries =
std::fs::read_dir(user_data_dir).map_err(|e| format!("Failed to read profile: {e}"))?;
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
if ROOT_LEVEL.contains(&name_str) || name_str == ".donut-import-repair" {
continue;
}
std::fs::rename(entry.path(), staging.join(name_str))
.map_err(|e| format!("Failed to relocate {name_str}: {e}"))?;
}
std::fs::rename(&staging, &default_dir)
.map_err(|e| format!("Failed to install {INITIAL_PROFILE_DIR}: {e}"))?;
// Now that the files are in the right place, put the network data where this
// platform reads it too.
let _ = layout::normalize_network_dir(&default_dir);
// And make sure the profile has a key, so the browser does not mint one
// mid-session and lose whatever it writes.
let _ = os_crypt::TargetKey::ensure(user_data_dir);
log::info!(
"Repaired legacy import layout at {} (moved profile content into {INITIAL_PROFILE_DIR}/)",
user_data_dir.display()
);
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn touch(path: &Path, contents: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, contents).unwrap();
}
#[test]
fn import_places_everything_under_default() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("Chrome").join("Default");
let dest = dir.path().join("profile");
touch(&source.join("Preferences"), b"{}");
touch(&source.join("Bookmarks"), b"{\"roots\":{}}");
let report = import_into(&source, &dest, "chromium", true).expect("import");
assert!(
dest.join("Default").join("Preferences").exists(),
"Chromium reads Default/, not the user-data-dir root"
);
assert!(
!dest.join("Preferences").exists(),
"nothing profile-scoped belongs at the root"
);
assert!(dest.join(os_crypt::KEY_FILE_NAME).exists());
assert!(report.bytes_copied > 0);
}
#[test]
fn import_rejects_a_firefox_profile_by_name() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("xyz.default-release");
let dest = dir.path().join("profile");
touch(&source.join("prefs.js"), b"");
touch(&source.join("places.sqlite"), b"");
let err = import_into(&source, &dest, "firefox", true).expect_err("must reject");
assert!(err.contains("IMPORT_SOURCE_NOT_CHROMIUM"));
assert!(
err.contains("Firefox"),
"the user needs to be told why, not just that it failed"
);
}
#[test]
fn import_rejects_an_arbitrary_folder() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("holiday-photos");
let dest = dir.path().join("profile");
touch(&source.join("IMG_0001.jpg"), b"not a profile");
let err = import_into(&source, &dest, "chromium", true).expect_err("must reject");
assert!(err.contains("IMPORT_SOURCE_NOT_CHROMIUM"));
}
#[test]
fn import_is_rerunnable_over_the_same_destination() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("Default");
let dest = dir.path().join("profile");
touch(&source.join("Preferences"), b"{}");
import_into(&source, &dest, "chromium", true).expect("first");
let key = std::fs::read(dest.join(os_crypt::KEY_FILE_NAME)).unwrap();
import_into(&source, &dest, "chromium", true).expect("second");
assert_eq!(
std::fs::read(dest.join(os_crypt::KEY_FILE_NAME)).unwrap(),
key,
"re-running must not orphan what the first run encrypted"
);
}
#[test]
fn legacy_layout_is_repaired_into_default() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("profile");
// Exactly what the old importer produced.
touch(&profile.join("Preferences"), b"{}");
touch(&profile.join("History"), b"");
touch(
&profile
.join("Local Storage")
.join("leveldb")
.join("CURRENT"),
b"",
);
touch(&profile.join("Local State"), b"{}");
assert!(repair_legacy_layout(&profile).unwrap());
assert!(profile.join("Default").join("Preferences").exists());
assert!(profile.join("Default").join("History").exists());
assert!(profile
.join("Default")
.join("Local Storage")
.join("leveldb")
.join("CURRENT")
.exists());
assert!(
profile.join("Local State").exists(),
"Local State belongs to the user-data dir, not the profile"
);
assert!(profile.join(os_crypt::KEY_FILE_NAME).exists());
assert!(!profile.join(".donut-import-repair").exists());
}
#[test]
fn repair_leaves_a_healthy_profile_alone() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("profile");
touch(&profile.join("Default").join("Preferences"), b"{}");
touch(&profile.join("Local State"), b"{}");
assert!(!repair_legacy_layout(&profile).unwrap());
assert!(profile.join("Default").join("Preferences").exists());
assert!(!profile.join("Default").join("Default").exists());
}
#[test]
fn repair_is_a_no_op_on_an_empty_profile() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("profile");
std::fs::create_dir_all(&profile).unwrap();
assert!(!repair_legacy_layout(&profile).unwrap());
}
#[test]
fn repair_is_idempotent() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("profile");
touch(&profile.join("Preferences"), b"{}");
assert!(repair_legacy_layout(&profile).unwrap());
assert!(!repair_legacy_layout(&profile).unwrap());
assert!(profile.join("Default").join("Preferences").exists());
}
}
+568
View File
@@ -0,0 +1,568 @@
//! Key material for profile import.
//!
//! Wayfern deliberately does not use the OS keyring. Every `os_crypt_async`
//! key provider is patched to read (or mint) `<user-data-dir>/os_crypt_key`
//! instead, so a profile directory is self-contained and portable. See
//! `wayfern/patches/extra/fingerprint/components-os_crypt-async-browser-*`.
//!
//! That portability is exactly why an imported Chrome profile carries nothing:
//! its secrets are sealed with a key held in the macOS Keychain / Windows DPAPI
//! / the Freedesktop secret service, and Wayfern never looks there. Import has
//! to open the source's lock and re-seal everything with Wayfern's.
//!
//! The on-disk format is per-platform and NOT interchangeable, matching the
//! provider that owns each tag in the patched Chromium 151 tree:
//!
//! | Host | `os_crypt_key` | Derivation | Cipher | Tag |
//! |---------|---------------------|-------------------------------------|--------------|-------|
//! | macOS | `base64(16 bytes)` | PBKDF2-HMAC-SHA1(saltysalt, 1003) | AES-128-CBC | `v10` |
//! | Linux | `base64(16 bytes)` | PBKDF2-HMAC-SHA1(saltysalt, 1) | AES-128-CBC | `v11` |
//! | Windows | 32 raw bytes | none, the bytes are the key | AES-256-GCM | `v10` |
//!
//! Linux must write `v11`, not `v10`: `PosixKeyProvider` owns `v10` with the
//! hardcoded "peanuts" password and `Encryptor::DecryptData` dispatches on the
//! tag prefix, so a `v10` record on Linux would be decrypted with the wrong key
//! forever.
use aes::cipher::{block_padding::Pkcs7, BlockModeDecrypt, BlockModeEncrypt, KeyIvInit};
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Key, Nonce};
// Windows stores the raw 32-byte key, so it neither encodes nor decodes
// base64; only the mac/Linux password paths below need the trait in scope.
#[cfg(not(target_os = "windows"))]
use base64::Engine;
use rand::RngExt;
use ring::pbkdf2;
use std::num::NonZeroU32;
use std::path::Path;
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;
/// Chromium's fixed PBKDF2 salt for every CBC-based os_crypt provider.
// Only the CBC hosts derive a key; Windows uses the file's bytes directly.
#[allow(dead_code)]
pub const SALT: &[u8] = b"saltysalt";
/// Chromium's fixed CBC IV: sixteen spaces.
pub const CBC_IV: [u8; 16] = [b' '; 16];
/// AES-256-GCM nonce length, prepended to the ciphertext by `Encryptor::Key::Encrypt`.
const GCM_NONCE_LEN: usize = 12;
/// The `os_crypt_key` name, at the root of the user-data dir.
pub const KEY_FILE_NAME: &str = "os_crypt_key";
/// `PBKDF2-HMAC-SHA1(password = "", salt = "saltysalt", iterations = 1)`.
///
/// Chromium retries every failed AES-128-CBC decrypt with this key
/// (`encryptor.cc`, crbug.com/40055416) because profiles created while the
/// keyring was unavailable were sealed with an empty password. Import has to do
/// the same or those records look corrupt.
pub const EMPTY_PASSWORD_KEY: [u8; 16] = [
0xd0, 0xd0, 0xec, 0x9c, 0x7d, 0x77, 0xd4, 0x3a, 0xc5, 0x41, 0x87, 0xfa, 0x48, 0x18, 0xd1, 0x7f,
];
/// The password Chromium's `PosixKeyProvider` uses when no secret service is
/// available (`--password-store=basic`). Records sealed with it carry `v10`.
// Read on Linux and by the known-answer tests; unreferenced on other hosts.
#[allow(dead_code)]
pub const POSIX_FALLBACK_PASSWORD: &[u8] = b"peanuts";
/// PBKDF2 iteration counts, per the provider that owns each platform.
// Each host only ever derives with its own count, but both are needed to read
// a profile produced on the other one.
#[allow(dead_code)]
pub const MAC_ITERATIONS: u32 = 1003;
#[allow(dead_code)]
pub const POSIX_ITERATIONS: u32 = 1;
/// Derive a 16-byte AES-128 key the way every CBC os_crypt provider does.
///
/// `password` is the raw bytes, never trimmed: Chromium passes the exact
/// `ReadFileToString` result to the KDF, so normalising here would silently
/// produce a different key and every decrypt would fail.
// Called from the mac and Linux branches only: `DPAPIKeyProvider` takes the
// 32 bytes on disk as the AES-256 key with no derivation step at all.
#[allow(dead_code)]
pub fn derive_key(password: &[u8], iterations: u32) -> [u8; 16] {
let mut key = [0u8; 16];
// ring rather than the `pbkdf2` crate: sha1 0.11 (digest 0.11) and
// pbkdf2 0.12 (digest 0.10) cannot coexist. ring is self-contained.
pbkdf2::derive(
pbkdf2::PBKDF2_HMAC_SHA1,
NonZeroU32::new(iterations).expect("iterations must be non-zero"),
SALT,
password,
&mut key,
);
key
}
/// One os_crypt cipher, keyed. Which variant applies is decided by the tag the
/// record carries, never by the host platform.
#[derive(Clone)]
pub enum CryptoKey {
Aes128Cbc([u8; 16]),
// Only Windows keys with GCM, but the variant has to exist everywhere so the
// tag dispatch in `SourceKeyring` stays platform-independent.
#[allow(dead_code)]
Aes256Gcm([u8; 32]),
}
impl CryptoKey {
/// Decrypt a *tagless* ciphertext (the caller has already stripped the
/// 3-byte version prefix).
pub fn decrypt(&self, ciphertext: &[u8]) -> Option<Vec<u8>> {
match self {
Self::Aes128Cbc(key) => {
if ciphertext.is_empty() {
return Some(Vec::new());
}
let mut buf = ciphertext.to_vec();
Aes128CbcDec::new(key.into(), &CBC_IV.into())
.decrypt_padded::<Pkcs7>(&mut buf)
.ok()
.map(<[u8]>::to_vec)
}
Self::Aes256Gcm(key) => {
if ciphertext.len() < GCM_NONCE_LEN {
return None;
}
let (nonce, body) = ciphertext.split_at(GCM_NONCE_LEN);
let nonce: [u8; GCM_NONCE_LEN] = nonce.try_into().ok()?;
Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key))
.decrypt(
&Nonce::from(nonce),
Payload {
msg: body,
aad: &[],
},
)
.ok()
}
}
}
/// Encrypt to a *tagless* ciphertext. The caller prepends the tag.
pub fn encrypt(&self, plaintext: &[u8]) -> Option<Vec<u8>> {
match self {
Self::Aes128Cbc(key) => {
let mut buf = vec![0u8; plaintext.len() + 16];
buf[..plaintext.len()].copy_from_slice(plaintext);
Aes128CbcEnc::new(key.into(), &CBC_IV.into())
.encrypt_padded::<Pkcs7>(&mut buf, plaintext.len())
.ok()
.map(<[u8]>::to_vec)
}
Self::Aes256Gcm(key) => {
let nonce: [u8; GCM_NONCE_LEN] = rand::rng().random();
let sealed = Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key))
.encrypt(
&Nonce::from(nonce),
Payload {
msg: plaintext,
aad: &[],
},
)
.ok()?;
// The nonce goes at the front, matching `Encryptor::Key::Encrypt`.
let mut out = Vec::with_capacity(GCM_NONCE_LEN + sealed.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&sealed);
Some(out)
}
}
}
}
/// Wayfern's key for the profile being created.
pub struct TargetKey {
key: CryptoKey,
tag: &'static [u8; 3],
}
impl TargetKey {
/// The tag the host platform's key provider claims.
pub const fn host_tag() -> &'static [u8; 3] {
#[cfg(target_os = "linux")]
{
b"v11"
}
#[cfg(not(target_os = "linux"))]
{
b"v10"
}
}
/// Build the key from the raw `os_crypt_key` file contents.
///
/// Returns `None` when the contents cannot key the host cipher — on Windows
/// that means anything other than exactly 32 bytes, which is what
/// `DPAPIKeyProvider` requires before it will adopt a portable key.
fn from_file_contents(contents: &[u8]) -> Option<Self> {
if contents.is_empty() {
return None;
}
#[cfg(target_os = "windows")]
{
let bytes: [u8; 32] = contents.try_into().ok()?;
Some(Self {
key: CryptoKey::Aes256Gcm(bytes),
tag: Self::host_tag(),
})
}
#[cfg(target_os = "macos")]
{
Some(Self {
key: CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS)),
tag: Self::host_tag(),
})
}
#[cfg(target_os = "linux")]
{
Some(Self {
key: CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS)),
tag: Self::host_tag(),
})
}
}
/// Fresh key material in the host platform's `os_crypt_key` format.
fn generate_file_contents() -> Vec<u8> {
#[cfg(target_os = "windows")]
{
// Windows stores the AES-256 key itself, so it must be 32 bytes.
let key: [u8; 32] = rand::rng().random();
key.to_vec()
}
#[cfg(not(target_os = "windows"))]
{
// mac/Linux store a *password* that is fed to PBKDF2. Wayfern mints
// `base64(16 random bytes)`; match it so the file is indistinguishable
// from one the browser wrote itself.
let raw: [u8; 16] = rand::rng().random();
base64::engine::general_purpose::STANDARD
.encode(raw)
.into_bytes()
}
}
/// Read the existing `os_crypt_key`, or mint and persist one.
///
/// Writing eagerly at import time — rather than letting the first launch do
/// it — is deliberate. The mac and Linux patches have no `else` branch when
/// the write fails, so the browser would run on an in-memory key that dies
/// with the process and orphans everything it wrote. Failing here instead
/// turns that silent data loss into a visible import error.
pub fn ensure(user_data_dir: &Path) -> Result<Self, String> {
let key_file = user_data_dir.join(KEY_FILE_NAME);
if let Ok(existing) = std::fs::read(&key_file) {
if let Some(key) = Self::from_file_contents(&existing) {
return Ok(key);
}
// Present but unusable (a Windows-format key on macOS, say, or a
// truncated write). Replacing it is safe only because import always
// re-encrypts into whatever key we end up with.
log::warn!(
"Replacing unusable {KEY_FILE_NAME} ({} bytes) at {}",
existing.len(),
key_file.display()
);
}
std::fs::create_dir_all(user_data_dir)
.map_err(|e| format!("Failed to create profile directory: {e}"))?;
let contents = Self::generate_file_contents();
std::fs::write(&key_file, &contents)
.map_err(|e| format!("Failed to write os_crypt_key: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&key_file, std::fs::Permissions::from_mode(0o600));
}
// Read back rather than trust the write: a key that did not land is the
// one failure mode that silently destroys every secret we are about to
// write with it.
let written =
std::fs::read(&key_file).map_err(|e| format!("Failed to verify os_crypt_key: {e}"))?;
if written != contents {
return Err("os_crypt_key verification failed after write".to_string());
}
Self::from_file_contents(&contents).ok_or_else(|| "Failed to derive os_crypt_key".to_string())
}
/// Seal a value the way Wayfern will expect to find it: `tag || ciphertext`.
pub fn encrypt(&self, plaintext: &[u8]) -> Option<Vec<u8>> {
let body = self.key.encrypt(plaintext)?;
let mut out = Vec::with_capacity(3 + body.len());
out.extend_from_slice(self.tag);
out.extend_from_slice(&body);
Some(out)
}
}
/// What a decrypt attempt produced.
pub enum Decrypted {
/// Recovered plaintext.
Value(Vec<u8>),
/// Already plaintext — no recognised version tag.
NotEncrypted,
/// Correctly identified but not openable: no key for the tag (Windows
/// App-Bound `v20`), or every candidate key failed.
Unrecoverable,
}
/// The source browser's keys, indexed by the tag the records carry.
///
/// Indexing by tag rather than by platform is not pedantry: a single Linux
/// profile can legitimately hold both `v10` (peanuts) and `v11` (keyring)
/// records, because the available secret service changes between sessions.
#[derive(Default)]
pub struct SourceKeyring {
pub v10: Option<CryptoKey>,
pub v11: Option<CryptoKey>,
/// Seen at least one `v20` (Windows App-Bound) record, which no third party
/// can open. Tracked so the import report can say so explicitly.
pub saw_app_bound: std::cell::Cell<bool>,
}
impl SourceKeyring {
pub fn is_empty(&self) -> bool {
self.v10.is_none() && self.v11.is_none()
}
/// Open one stored value, dispatching on its version tag exactly as
/// `Encryptor::DecryptData` does.
pub fn decrypt(&self, stored: &[u8]) -> Decrypted {
if stored.len() < 3 {
return if stored.is_empty() {
Decrypted::Value(Vec::new())
} else {
Decrypted::NotEncrypted
};
}
let (tag, body) = stored.split_at(3);
let key = match tag {
b"v10" => self.v10.as_ref(),
b"v11" => self.v11.as_ref(),
b"v20" => {
// App-Bound Encryption. The key is wrapped by the SYSTEM-level Chrome
// Elevation Service, which validates the calling binary. There is no
// legitimate way for us to unwrap it.
self.saw_app_bound.set(true);
return Decrypted::Unrecoverable;
}
_ => return Decrypted::NotEncrypted,
};
let Some(key) = key else {
return Decrypted::Unrecoverable;
};
if let Some(plaintext) = key.decrypt(body) {
return Decrypted::Value(plaintext);
}
// Chromium's own fallback for CBC records sealed with an empty password.
if matches!(key, CryptoKey::Aes128Cbc(_)) {
if let Some(plaintext) = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY).decrypt(body) {
return Decrypted::Value(plaintext);
}
}
Decrypted::Unrecoverable
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn empty_password_key_matches_chromium_constant() {
// Locks the constant against the value Chromium hardcodes in encryptor.cc.
assert_eq!(derive_key(b"", POSIX_ITERATIONS), EMPTY_PASSWORD_KEY);
}
#[test]
fn peanuts_key_matches_known_vector() {
// PBKDF2-HMAC-SHA1("peanuts", "saltysalt", 1, 16). Any drift here silently
// breaks every Linux `--password-store=basic` import.
assert_eq!(
derive_key(POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS),
[
0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27,
0x78
]
);
}
#[test]
fn cbc_round_trip() {
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
let sealed = key.encrypt(b"session-token").expect("encrypt");
assert_eq!(key.decrypt(&sealed).expect("decrypt"), b"session-token");
}
#[test]
fn cbc_round_trip_empty_plaintext() {
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
let sealed = key.encrypt(b"").expect("encrypt");
// PKCS7 always emits a full padding block, so this must not be empty.
assert_eq!(sealed.len(), 16);
assert!(key.decrypt(&sealed).expect("decrypt").is_empty());
}
#[test]
fn gcm_round_trip_with_fresh_nonce_each_time() {
let key = CryptoKey::Aes256Gcm([7u8; 32]);
let a = key.encrypt(b"session-token").expect("encrypt");
let b = key.encrypt(b"session-token").expect("encrypt");
assert_ne!(a, b, "nonce must be random per call");
assert_eq!(key.decrypt(&a).expect("decrypt"), b"session-token");
assert_eq!(key.decrypt(&b).expect("decrypt"), b"session-token");
}
#[test]
fn gcm_rejects_tampered_ciphertext() {
let key = CryptoKey::Aes256Gcm([7u8; 32]);
let mut sealed = key.encrypt(b"session-token").expect("encrypt");
let last = sealed.len() - 1;
sealed[last] ^= 0xff;
assert!(key.decrypt(&sealed).is_none());
}
#[test]
fn target_key_is_stable_across_calls() {
let dir = TempDir::new().unwrap();
let first = TargetKey::ensure(dir.path()).expect("mint");
let sealed = first.encrypt(b"value").expect("encrypt");
let second = TargetKey::ensure(dir.path()).expect("reuse");
// Re-running import over the same directory must not orphan what the
// previous run wrote.
let key_file = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
let reloaded = TargetKey::from_file_contents(&key_file).expect("reload");
assert_eq!(
reloaded.encrypt(b"probe").map(|v| v[..3].to_vec()),
second.encrypt(b"probe").map(|v| v[..3].to_vec())
);
let mut keyring = SourceKeyring::default();
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
install_host_key(&mut keyring, &contents);
match keyring.decrypt(&sealed) {
Decrypted::Value(v) => assert_eq!(v, b"value"),
_ => panic!("target key must round-trip through the source keyring"),
}
}
#[test]
fn minted_key_matches_wayfern_file_format() {
let dir = TempDir::new().unwrap();
TargetKey::ensure(dir.path()).expect("mint");
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
#[cfg(target_os = "windows")]
assert_eq!(
contents.len(),
32,
"DPAPIKeyProvider only adopts a 32-byte portable key"
);
#[cfg(not(target_os = "windows"))]
{
// Wayfern writes base64(16 random bytes) = 24 ASCII chars.
assert_eq!(contents.len(), 24);
let text = String::from_utf8(contents).expect("ascii");
assert!(
base64::engine::general_purpose::STANDARD
.decode(&text)
.map(|b| b.len())
== Ok(16),
"expected base64 of 16 bytes, got {text}"
);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.path().join(KEY_FILE_NAME))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
}
#[test]
fn unknown_tag_is_treated_as_plaintext_not_as_loss() {
let keyring = SourceKeyring::default();
assert!(matches!(
keyring.decrypt(b"plain cookie value"),
Decrypted::NotEncrypted
));
}
#[test]
fn app_bound_records_are_flagged_unrecoverable() {
let keyring = SourceKeyring::default();
let mut sealed = b"v20".to_vec();
sealed.extend_from_slice(&[0u8; 40]);
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
assert!(
keyring.saw_app_bound.get(),
"v20 must be reported to the user, not silently dropped"
);
}
#[test]
fn missing_key_for_known_tag_is_unrecoverable() {
let keyring = SourceKeyring::default();
let mut sealed = b"v10".to_vec();
sealed.extend_from_slice(&[0u8; 32]);
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
}
#[test]
fn empty_password_fallback_recovers_the_record() {
// A record sealed with the empty-password key must still open when the
// keyring holds a different primary key, mirroring Chromium.
let sealed_body = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY)
.encrypt(b"legacy")
.unwrap();
let mut stored = b"v10".to_vec();
stored.extend_from_slice(&sealed_body);
let keyring = SourceKeyring {
v10: Some(CryptoKey::Aes128Cbc(derive_key(b"a different key", 1003))),
..Default::default()
};
match keyring.decrypt(&stored) {
Decrypted::Value(v) => assert_eq!(v, b"legacy"),
_ => panic!("empty-password fallback must be attempted"),
}
}
/// Load the host-format key into a keyring under the host tag, for tests
/// that need to verify what we wrote is what Wayfern will read.
fn install_host_key(keyring: &mut SourceKeyring, contents: &[u8]) {
#[cfg(target_os = "windows")]
{
let bytes: [u8; 32] = contents.try_into().unwrap();
keyring.v10 = Some(CryptoKey::Aes256Gcm(bytes));
}
#[cfg(target_os = "macos")]
{
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS)));
}
#[cfg(target_os = "linux")]
{
keyring.v11 = Some(CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS)));
}
}
}
+109
View File
@@ -0,0 +1,109 @@
//! What an import actually carried across.
//!
//! Import is best-effort by nature: a locked keychain, a Windows App-Bound
//! cookie store or a schema too old for Chromium to migrate all mean some
//! subset does not survive, and none of them should abort the whole operation.
//! The report is how that stays honest — every skipped store is a counted
//! warning rather than a silent zero.
use serde::{Deserialize, Serialize};
/// Stable warning codes. The frontend maps these to
/// `importProfile.warnings.*`, so they are part of the API contract: rename one
/// and the user sees a missing translation.
pub mod warning {
/// The source browser's key could not be read, so cookies/passwords were
/// left encrypted and are unreadable in the new profile.
pub const SECRETS_NOT_MIGRATED: &str = "secretsNotMigrated";
/// Windows App-Bound Encryption (Chrome 127+). Unrecoverable by design.
pub const APP_BOUND_ENCRYPTED: &str = "appBoundEncrypted";
/// A store's schema predates what Chromium will migrate; it would have been
/// deleted on first launch, so it was skipped instead.
pub const STORE_TOO_OLD: &str = "storeTooOld";
/// A store's schema is newer than this Chromium can read.
pub const STORE_TOO_NEW: &str = "storeTooNew";
/// The source browser was running; databases were snapshotted but LevelDB
/// site data may be incomplete.
pub const SOURCE_BROWSER_RUNNING: &str = "sourceBrowserRunning";
/// Tracked preferences lost their MACs and will reset to defaults.
pub const SECURE_PREFERENCES_RESET: &str = "securePreferencesReset";
/// At least one extension could not be carried.
pub const EXTENSIONS_PARTIAL: &str = "extensionsPartial";
/// A database was unreadable and was skipped rather than copied corrupt.
pub const STORE_UNREADABLE: &str = "storeUnreadable";
}
/// Per-profile outcome, returned alongside each item in a batch import.
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProfileImportReport {
/// Cookies whose value is readable in the new profile.
pub cookies_migrated: usize,
/// Cookies carried over as rows but whose value could not be recovered.
pub cookies_unrecoverable: usize,
pub passwords_migrated: usize,
pub passwords_unrecoverable: usize,
/// Saved cards / IBANs / autofill secrets re-encrypted.
pub payment_methods_migrated: usize,
pub payment_methods_unrecoverable: usize,
pub extensions_migrated: usize,
pub history_entries: usize,
pub bookmarks: usize,
/// Origins with Local Storage data.
pub local_storage_origins: usize,
pub bytes_copied: u64,
/// Stable codes from [`warning`], deduplicated, in insertion order.
pub warnings: Vec<String>,
}
impl ProfileImportReport {
pub fn warn(&mut self, code: &str) {
if !self.warnings.iter().any(|w| w == code) {
self.warnings.push(code.to_string());
}
}
/// True when nothing readable came across. Used to decide whether the UI
/// should present the import as a success or as a warning.
pub fn is_empty_import(&self) -> bool {
self.cookies_migrated == 0
&& self.passwords_migrated == 0
&& self.history_entries == 0
&& self.bookmarks == 0
&& self.local_storage_origins == 0
&& self.extensions_migrated == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn warnings_are_deduplicated_in_order() {
let mut report = ProfileImportReport::default();
report.warn(warning::STORE_TOO_OLD);
report.warn(warning::SECRETS_NOT_MIGRATED);
report.warn(warning::STORE_TOO_OLD);
assert_eq!(
report.warnings,
vec![
warning::STORE_TOO_OLD.to_string(),
warning::SECRETS_NOT_MIGRATED.to_string()
]
);
}
#[test]
fn empty_import_detection_ignores_unrecoverable_counts() {
let mut report = ProfileImportReport {
cookies_unrecoverable: 500,
..Default::default()
};
assert!(
report.is_empty_import(),
"500 unreadable cookies is still nothing carried"
);
report.history_entries = 1;
assert!(!report.is_empty_import());
}
}
File diff suppressed because it is too large Load Diff
+74 -26
View File
@@ -9,6 +9,7 @@ use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
use crate::events;
use crate::profile::types::{get_host_os, BrowserProfile, SyncMode};
use crate::profile::ProfileManager;
use crate::profile_import::report::ProfileImportReport;
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::WayfernConfig;
@@ -28,6 +29,9 @@ pub struct DetectedProfile {
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct ImportProfileItem {
pub source_path: String,
/// The source browser family (`chromium`, `brave`, `edge`, …). Load-bearing:
/// it selects which Keychain / secret-service item holds the key that
/// unlocks the source's cookies and passwords.
#[serde(default = "default_import_browser_type")]
pub browser_type: String,
pub new_profile_name: String,
@@ -35,6 +39,10 @@ pub struct ImportProfileItem {
pub proxy_id: Option<String>,
#[serde(default)]
pub vpn_id: Option<String>,
/// Import even though the source browser is running. Databases are still
/// snapshotted consistently, but LevelDB site data may be mid-write.
#[serde(default)]
pub allow_running: Option<bool>,
}
fn default_import_browser_type() -> String {
@@ -61,6 +69,8 @@ pub struct ProfileImportItemResult {
pub profile_id: Option<String>,
/// Structured `{"code": …}` error string when status is "failed".
pub error: Option<String>,
/// What actually came across. Present when status is "imported".
pub report: Option<ProfileImportReport>,
}
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
@@ -719,6 +729,7 @@ impl ProfileImporter {
status: "failed".to_string(),
profile_id: None,
error: Some(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string()),
report: None,
});
continue;
}
@@ -735,6 +746,7 @@ impl ProfileImporter {
status: "skipped".to_string(),
profile_id: None,
error: None,
report: None,
});
continue;
}
@@ -757,10 +769,11 @@ impl ProfileImporter {
item.vpn_id.clone(),
group_id.clone(),
wayfern_config.clone(),
item.allow_running.unwrap_or(false),
)
.await
{
Ok(profile) => {
Ok((profile, report)) => {
imported_count += 1;
completed += 1;
emit_import_progress(total, completed, index, &final_name, "imported");
@@ -771,6 +784,7 @@ impl ProfileImporter {
status: "imported".to_string(),
profile_id: Some(profile.id.to_string()),
error: None,
report: Some(report),
});
}
Err(e) => {
@@ -785,6 +799,7 @@ impl ProfileImporter {
status: "failed".to_string(),
profile_id: None,
error: Some(error_to_code_string(e)),
report: None,
});
}
}
@@ -809,7 +824,8 @@ impl ProfileImporter {
vpn_id: Option<String>,
group_id: Option<String>,
wayfern_config: Option<WayfernConfig>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
allow_running: bool,
) -> Result<(BrowserProfile, ProfileImportReport), Box<dyn std::error::Error>> {
let source_path = Path::new(source_path);
if !source_path.exists() {
return Err(
@@ -847,39 +863,54 @@ impl ProfileImporter {
create_dir_all(&new_profile_uuid_dir)?;
create_dir_all(&new_profile_data_dir)?;
// Profile dirs can be multiple GB — keep the copy off the async runtime.
let copy_source = source_path.to_path_buf();
let copy_dest = new_profile_data_dir.clone();
let copy_result = match tokio::task::spawn_blocking(move || {
Self::copy_directory_recursive(&copy_source, &copy_dest).map_err(|e| e.to_string())
// Profile dirs can be multiple GB and the migration hits SQLite and the
// OS keyring — keep all of it off the async runtime.
let migrate_source = source_path.to_path_buf();
let migrate_dest = new_profile_data_dir.clone();
let source_family = browser_type.to_string();
let migrate_result = match tokio::task::spawn_blocking(move || {
crate::profile_import::import_into(
&migrate_source,
&migrate_dest,
&source_family,
allow_running,
)
})
.await
{
Ok(r) => r,
Err(e) => {
// The copy task died (panic, or runtime shutdown mid-import). Clean up
// like every other error path here, or the half-copied — possibly
// multi-GB — directory is orphaned with no metadata pointing at it, so
// nothing ever reclaims it.
// The task died (panic, or runtime shutdown mid-import). Clean up like
// every other error path here, or the half-copied — possibly multi-GB
// — directory is orphaned with no metadata pointing at it, so nothing
// ever reclaims it.
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
return Err(
serde_json::json!({
"code": "INTERNAL_ERROR",
"params": { "detail": format!("Profile copy task failed: {e}") },
"params": { "detail": format!("Profile import task failed: {e}") },
})
.to_string()
.into(),
);
}
};
if let Err(e) = copy_result {
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
return Err(
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e } })
.to_string()
.into(),
);
}
let report = match migrate_result {
Ok(report) => report,
Err(e) => {
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
// Structured codes (an unimportable source, a running browser) pass
// through so the frontend can translate them; anything else is
// internal.
return Err(if e.starts_with('{') {
e.into()
} else {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e } })
.to_string()
.into()
});
}
};
let version = match self.get_default_version_for_browser(mapped) {
Ok(version) => version,
@@ -1017,13 +1048,30 @@ impl ProfileImporter {
self.profile_manager.save_profile(&profile)?;
log::info!(
"Successfully imported profile '{}' from '{}'",
new_profile_name,
source_path.display()
);
if report.is_empty_import() {
// Not an error — an empty source profile imports legitimately — but it is
// the exact symptom the old layout bug produced, so it is worth a loud
// line in the log rather than a silent success.
log::warn!(
"Imported profile '{}' from '{}' carried no readable data (warnings: {:?})",
new_profile_name,
source_path.display(),
report.warnings
);
} else {
log::info!(
"Imported profile '{}' from '{}': {} cookies, {} passwords, {} history entries ({} unrecoverable secrets, warnings: {:?})",
new_profile_name,
source_path.display(),
report.cookies_migrated,
report.passwords_migrated,
report.history_entries,
report.cookies_unrecoverable + report.passwords_unrecoverable,
report.warnings
);
}
Ok(profile)
Ok((profile, report))
}
fn get_default_version_for_browser(
+106 -47
View File
@@ -1375,53 +1375,7 @@ impl ProxyManager {
}
}
// 4 parts: could be host:port:user:pass OR user:pass:host:port
4 => {
// Try to detect which format
let port_at_1 = parts[1].parse::<u16>().is_ok();
let port_at_3 = parts[3].parse::<u16>().is_ok();
match (port_at_1, port_at_3) {
// host:port:user:pass
(true, false) => {
let port = parts[1].parse::<u16>().unwrap();
ProxyParseResult::Parsed(ParsedProxyLine {
proxy_type: "http".to_string(),
host: parts[0].to_string(),
port,
username: Some(parts[2].to_string()),
password: Some(parts[3].to_string()),
vless_uri: None,
original_line: line.to_string(),
})
}
// user:pass:host:port
(false, true) => {
let port = parts[3].parse::<u16>().unwrap();
ProxyParseResult::Parsed(ParsedProxyLine {
proxy_type: "http".to_string(),
host: parts[2].to_string(),
port,
username: Some(parts[0].to_string()),
password: Some(parts[1].to_string()),
vless_uri: None,
original_line: line.to_string(),
})
}
// Both could be ports - ambiguous
(true, true) => ProxyParseResult::Ambiguous {
line: line.to_string(),
possible_formats: vec![
"host:port:username:password".to_string(),
"username:password:host:port".to_string(),
],
},
// Neither is a valid port
(false, false) => ProxyParseResult::Invalid {
line: line.to_string(),
reason: "No valid port number found".to_string(),
},
}
}
4 => Self::parse_colon_separated_quad(&parts, "http", line),
_ => ProxyParseResult::Invalid {
line: line.to_string(),
reason: format!("Unexpected format with {} parts", parts.len()),
@@ -1429,6 +1383,51 @@ impl ProxyManager {
}
}
// Resolve a four-part colon-separated body, which is either
// host:port:username:password or username:password:host:port. The port
// position tells the two apart; when both positions parse as a port the
// caller has to ask the user.
fn parse_colon_separated_quad(parts: &[&str], proxy_type: &str, line: &str) -> ProxyParseResult {
let port_at_1 = parts[1].parse::<u16>().ok();
let port_at_3 = parts[3].parse::<u16>().ok();
match (port_at_1, port_at_3) {
// host:port:user:pass
(Some(port), None) => ProxyParseResult::Parsed(ParsedProxyLine {
proxy_type: proxy_type.to_string(),
host: parts[0].to_string(),
port,
username: Some(parts[2].to_string()),
password: Some(parts[3].to_string()),
vless_uri: None,
original_line: line.to_string(),
}),
// user:pass:host:port
(None, Some(port)) => ProxyParseResult::Parsed(ParsedProxyLine {
proxy_type: proxy_type.to_string(),
host: parts[2].to_string(),
port,
username: Some(parts[0].to_string()),
password: Some(parts[1].to_string()),
vless_uri: None,
original_line: line.to_string(),
}),
// Both could be ports - ambiguous
(Some(_), Some(_)) => ProxyParseResult::Ambiguous {
line: line.to_string(),
possible_formats: vec![
"host:port:username:password".to_string(),
"username:password:host:port".to_string(),
],
},
// Neither is a valid port
(None, None) => ProxyParseResult::Invalid {
line: line.to_string(),
reason: "No valid port number found".to_string(),
},
}
}
// Try to parse URL format: protocol://username:password@host:port
fn try_parse_url_format(line: &str) -> Option<ProxyParseResult> {
if line.starts_with("vless://") {
@@ -1500,6 +1499,15 @@ impl ProxyManager {
}
}
} else {
// Vendors also hand out the colon-separated body behind a scheme, as in
// socks5://host:port:user:pass, so try that before plain host:port. An
// IPv6 literal splits into four too ("[", "", "1]", "8080" for [::1]:8080),
// so require every field to be populated before reading it that way.
let parts: Vec<&str> = rest.split(':').collect();
if parts.len() == 4 && parts.iter().all(|part| !part.is_empty()) {
return Some(Self::parse_colon_separated_quad(&parts, protocol, line));
}
// No auth, just host:port
if let Some(colon_pos) = rest.rfind(':') {
let host = &rest[..colon_pos];
@@ -3876,6 +3884,57 @@ mod tests {
_ => panic!("Expected Parsed"),
}
// Scheme in front of the colon-separated body
let results = ProxyManager::parse_txt_proxies("socks5://1.2.3.4:1080:admin:secret\n");
match &results[0] {
ProxyParseResult::Parsed(p) => {
assert_eq!(p.proxy_type, "socks5");
assert_eq!(p.host, "1.2.3.4");
assert_eq!(p.port, 1080);
assert_eq!(p.username.as_deref(), Some("admin"));
assert_eq!(p.password.as_deref(), Some("secret"));
}
_ => panic!("Expected Parsed"),
}
// Same, with the credentials in front
let results = ProxyManager::parse_txt_proxies("https://admin:secret:proxy.com:8443\n");
match &results[0] {
ProxyParseResult::Parsed(p) => {
assert_eq!(p.proxy_type, "https");
assert_eq!(p.host, "proxy.com");
assert_eq!(p.port, 8443);
assert_eq!(p.username.as_deref(), Some("admin"));
assert_eq!(p.password.as_deref(), Some("secret"));
}
_ => panic!("Expected Parsed"),
}
// An IPv6 literal splits into four parts as well, and must not be read as
// the colon-separated form
let results = ProxyManager::parse_txt_proxies("http://[::1]:8080\n");
match &results[0] {
ProxyParseResult::Parsed(p) => {
assert_eq!(p.host, "[::1]");
assert_eq!(p.port, 8080);
assert!(p.username.is_none());
}
_ => panic!("Expected Parsed"),
}
// A scheme-prefixed body that is ambiguous stays ambiguous
let results = ProxyManager::parse_txt_proxies("socks5://1234:5678:9012:3456\n");
match &results[0] {
ProxyParseResult::Ambiguous {
line,
possible_formats,
} => {
assert_eq!(line, "socks5://1234:5678:9012:3456");
assert_eq!(possible_formats.len(), 2);
}
_ => panic!("Expected Ambiguous"),
}
// Ambiguous: both positions could be ports
let results = ProxyManager::parse_txt_proxies("1234:5678:9012:3456\n");
match &results[0] {
+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);
+89 -10
View File
@@ -109,6 +109,37 @@ fn is_critical_file(path: &str) -> bool {
.any(|pattern| path.contains(pattern))
}
/// How many failed paths to name before collapsing the rest into a count.
const MAX_LISTED_FAILURES: usize = 10;
/// Aggregate a batch of failed transfers into the message the user sees.
///
/// Whatever breaks a sync usually breaks every file the same way — one
/// unreachable storage host, one rejected signature — so the per-file causes
/// were dropped and only the paths survived into the message. That left users
/// staring at a list of filenames with nothing to act on. Carry the first
/// cause through, and stop pasting hundreds of paths into a toast.
fn critical_failure_message(action: &str, failures: &[(String, String)]) -> String {
let listed: Vec<&str> = failures
.iter()
.take(MAX_LISTED_FAILURES)
.map(|(path, _)| path.as_str())
.collect();
let hidden = failures.len().saturating_sub(listed.len());
let files = if hidden > 0 {
format!("{} (and {} more)", listed.join(", "), hidden)
} else {
listed.join(", ")
};
match failures.first() {
Some((_, cause)) => format!(
"Critical files failed to {action}: {files}. Cause: {cause}. Sync aborted to prevent data loss."
),
None => format!("Critical files failed to {action}: {files}. Sync aborted to prevent data loss."),
}
}
/// Validate that a manifest-supplied relative file path is safe to join onto a
/// profile directory before writing/deleting. The manifest is remote-controlled
/// (a self-hosted or compromised sync server, a MITM on a plaintext Regular-mode
@@ -1283,10 +1314,9 @@ impl SyncEngine {
}
if !critical_failures.is_empty() {
let file_list: Vec<&str> = critical_failures.iter().map(|(p, _)| p.as_str()).collect();
return Err(SyncError::IoError(format!(
"Critical files failed to upload: {}. Sync aborted to prevent data loss.",
file_list.join(", ")
return Err(SyncError::IoError(critical_failure_message(
"upload",
&critical_failures,
)));
}
@@ -1559,10 +1589,9 @@ impl SyncEngine {
}
if !critical_failures.is_empty() {
let file_list: Vec<&str> = critical_failures.iter().map(|(p, _)| p.as_str()).collect();
return Err(SyncError::IoError(format!(
"Critical files failed to download: {}. Sync aborted to prevent data loss.",
file_list.join(", ")
return Err(SyncError::IoError(critical_failure_message(
"download",
&critical_failures,
)));
}
@@ -2055,6 +2084,13 @@ impl SyncEngine {
manager.get_extension(ext_id).ok()
};
// A linked extension is an absolute path on this machine with no payload in
// the store. Uploading it would publish metadata another device could never
// resolve, so it stays local whatever queued this run.
if local_ext.as_ref().is_some_and(|e| e.is_linked()) {
return Ok(());
}
let remote_key = format!("extensions/{}.json", ext_id);
let stat = self.client.stat(&remote_key).await?;
@@ -3222,7 +3258,9 @@ pub async fn enable_extension_group_sync_if_needed(extension_group_id: &str) ->
manager
.get_extension(ext_id)
.ok()
.map(|e| e.sync_enabled)
// A linked extension has no binary to hand the other device, only a
// path that means nothing there, so the cascade must not pick it up.
.map(|e| e.sync_enabled || e.is_linked())
.unwrap_or(true)
};
if !already_synced {
@@ -3954,7 +3992,9 @@ pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Resul
.map_err(|e| format!("Failed to list extensions: {e}"))?
};
for ext in &exts {
if !ext.sync_enabled {
// Linked extensions are machine-local by definition and are skipped
// rather than reported as a failure on every sync setup.
if !ext.sync_enabled && !ext.is_linked() {
if let Err(e) = set_extension_sync_enabled(app_handle.clone(), ext.id.clone(), true).await {
log::warn!("Failed to enable sync for extension {}: {e}", ext.id);
}
@@ -4000,6 +4040,11 @@ pub async fn set_extension_sync_enabled(
};
if enabled {
// A linked extension is a path on this machine and nothing else; there is
// no payload to upload and the path would be meaningless on another device.
if ext.is_linked() {
return Err(serde_json::json!({ "code": "EXTENSION_LINKED_CANNOT_SYNC" }).to_string());
}
ensure_sync_configured(&app_handle).await?;
}
@@ -4247,6 +4292,40 @@ pub async fn rollover_encryption_for_all_entities(
mod tests {
use super::*;
#[test]
fn test_critical_failure_message_carries_the_cause() {
// A self-hosted server that hands out unreachable presigned URLs fails
// every file with the same connect error. Naming only the files told the
// user nothing about why, which is what made this undiagnosable.
let failures = vec![
(
"Default/Cookies".to_string(),
"Failed to upload Default/Cookies after 3 retries: error sending request".to_string(),
),
("Local State".to_string(), "same".to_string()),
];
let message = critical_failure_message("upload", &failures);
assert!(message.contains("Default/Cookies"));
assert!(message.contains("Local State"));
assert!(message.contains("Cause: Failed to upload Default/Cookies"));
assert!(message.contains("Sync aborted to prevent data loss."));
}
#[test]
fn test_critical_failure_message_collapses_long_lists() {
let failures: Vec<(String, String)> = (0..25)
.map(|i| (format!("file-{i}"), "connect error".to_string()))
.collect();
let message = critical_failure_message("download", &failures);
assert!(message.contains("file-0"));
assert!(message.contains(&format!("file-{}", MAX_LISTED_FAILURES - 1)));
assert!(!message.contains(&format!("file-{MAX_LISTED_FAILURES}")));
assert!(message.contains("(and 15 more)"));
assert!(message.contains("failed to download"));
}
#[test]
fn test_is_safe_manifest_path() {
// Legitimate profile-relative paths are accepted.
+4 -32
View File
@@ -530,38 +530,10 @@ pub fn delete_traffic_stats(id: &str) -> bool {
removed
}
/// Best-effort secure erase: overwrite the file's bytes with zeros and flush
/// before unlinking, so the traffic history isn't trivially recoverable from
/// the freed blocks. On copy-on-write / SSD storage the OS may still retain
/// old blocks — this is a best-effort mitigation, not a guarantee.
fn secure_remove_file(path: &std::path::Path) -> std::io::Result<()> {
use std::io::Write;
if let Ok(meta) = fs::metadata(path) {
let len = meta.len();
if len > 0 {
if let Ok(mut f) = fs::OpenOptions::new().write(true).open(path) {
let zeros = vec![0u8; 8192];
let mut remaining = len;
// The overwrite is best-effort and must never gate the unlink: a write
// failure part-way (ENOSPC on a copy-on-write volume, EIO) would
// otherwise leave the file both un-wiped and un-deleted, which is
// strictly worse than the plain remove this replaced — and the caller
// reports success either way, so the history would silently survive a
// clear.
while remaining > 0 {
let chunk = remaining.min(zeros.len() as u64) as usize;
if f.write_all(&zeros[..chunk]).is_err() {
break;
}
remaining -= chunk as u64;
}
let _ = f.flush();
let _ = f.sync_all();
}
}
}
fs::remove_file(path)
}
/// Best-effort secure erase. Shared with the ephemeral-profile teardown, which
/// needs exactly the same "zero then unlink, never let the overwrite gate the
/// unlink" behaviour; see `crate::fs_secure` for the caveats.
use crate::fs_secure::secure_remove_file;
/// Clear all traffic stats (used when clearing cache), securely erasing each
/// file first.
@@ -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);
}
}
+13 -8
View File
@@ -657,9 +657,13 @@ impl WayfernManager {
let fingerprint_json = serde_json::to_string(&fingerprint)
.map_err(|e| format!("Failed to serialize fingerprint: {e}"))?;
// Report the platform the engine actually produced alongside the one that
// was asked for. Logging only the request made this line useless for
// diagnosing a fingerprint that came back as something else.
log::info!(
"Generated Wayfern fingerprint for OS: {}, fields: {:?}",
"Generated Wayfern fingerprint for requested OS: {}, produced platform: {:?}, fields: {:?}",
os,
fingerprint.get("platform").and_then(|p| p.as_str()),
fingerprint
.as_object()
.map(|o| o.keys().collect::<Vec<_>>())
@@ -721,12 +725,10 @@ impl WayfernManager {
};
if key_path.exists() {
let key_text = std::fs::read_to_string(&key_path).unwrap_or_default();
log::info!(
"Pre-launch: os_crypt_key present ({} bytes, content: '{}')",
key_text.len(),
key_text.trim()
);
// Length only. The contents are the profile's encryption key, and this
// log is the first thing a user attaches to a bug report.
let key_len = std::fs::metadata(&key_path).map(|m| m.len()).unwrap_or(0);
log::info!("Pre-launch: os_crypt_key present ({key_len} bytes)");
} else {
log::warn!("Pre-launch: os_crypt_key NOT FOUND");
}
@@ -885,9 +887,12 @@ impl WayfernManager {
args.push(format!("--wayfern-profile-color={profile_color}"));
let mut wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
// Waiting is only meaningful for a plan a token can actually be minted for.
// On "any active plan" this stalled every Solo launch by the full three
// seconds waiting for a token the backend will never issue to them.
if wayfern_token.is_none()
&& crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.is_entitled_to_wayfern_token()
.await
{
// Brief wait for the background token fetch — when the API is healthy
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.28.2",
"version": "0.29.5",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+52 -35
View File
@@ -464,15 +464,10 @@ async fn test_local_proxy_direct() -> Result<(), Box<dyn std::error::Error + Sen
);
// Verify proxy is listening
sleep(Duration::from_millis(500)).await;
match TcpStream::connect(("127.0.0.1", local_port)).await {
Ok(_) => {
println!("Proxy is listening on port {local_port}");
}
Err(e) => {
return Err(format!("Proxy port {local_port} is not listening: {e}").into());
}
if !wait_for_port_open(local_port, Duration::from_secs(10)).await {
return Err(format!("Proxy port {local_port} is not listening").into());
}
println!("Proxy is listening on port {local_port}");
// Test making an HTTP request through the proxy
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
@@ -524,11 +519,10 @@ async fn test_chained_local_proxies() -> Result<(), Box<dyn std::error::Error +
println!("First proxy started on port {}", proxy1_port);
// Wait for first proxy to be ready
sleep(Duration::from_millis(500)).await;
match TcpStream::connect(("127.0.0.1", proxy1_port)).await {
Ok(_) => println!("First proxy is ready"),
Err(e) => return Err(format!("First proxy not ready: {e}").into()),
if !wait_for_port_open(proxy1_port, Duration::from_secs(10)).await {
return Err("First proxy not ready".into());
}
println!("First proxy is ready");
// Start second proxy chained to first proxy
let output2 = TestUtils::execute_command(
@@ -565,11 +559,10 @@ async fn test_chained_local_proxies() -> Result<(), Box<dyn std::error::Error +
);
// Wait for second proxy to be ready
sleep(Duration::from_millis(500)).await;
match TcpStream::connect(("127.0.0.1", proxy2_port)).await {
Ok(_) => println!("Second proxy is ready"),
Err(e) => return Err(format!("Second proxy not ready: {e}").into()),
if !wait_for_port_open(proxy2_port, Duration::from_secs(10)).await {
return Err("Second proxy not ready".into());
}
println!("Second proxy is ready");
// Test making an HTTP request through the chained proxy
let mut stream = TcpStream::connect(("127.0.0.1", proxy2_port)).await?;
@@ -669,16 +662,11 @@ async fn test_local_proxy_with_http_upstream(
println!("Proxy started: id={}, port={}", proxy_id, local_port);
// Verify proxy is listening
sleep(Duration::from_millis(500)).await;
match TcpStream::connect(("127.0.0.1", local_port)).await {
Ok(_) => {
println!("Proxy is listening on port {local_port}");
}
Err(e) => {
upstream_handle.abort();
return Err(format!("Proxy port {local_port} is not listening: {e}").into());
}
if !wait_for_port_open(local_port, Duration::from_secs(10)).await {
upstream_handle.abort();
return Err(format!("Proxy port {local_port} is not listening").into());
}
println!("Proxy is listening on port {local_port}");
// Cleanup
tracker.cleanup_all().await;
@@ -955,11 +943,10 @@ async fn test_proxy_stop() -> Result<(), Box<dyn std::error::Error + Send + Sync
let local_port = config["localPort"].as_u64().unwrap() as u16;
// Verify proxy is running
sleep(Duration::from_millis(500)).await;
match TcpStream::connect(("127.0.0.1", local_port)).await {
Ok(_) => println!("Proxy is running"),
Err(_) => return Err("Proxy is not running".into()),
if !wait_for_port_open(local_port, Duration::from_secs(10)).await {
return Err("Proxy is not running".into());
}
println!("Proxy is running");
// Stop the proxy
let stop_output =
@@ -969,14 +956,11 @@ async fn test_proxy_stop() -> Result<(), Box<dyn std::error::Error + Send + Sync
return Err("Failed to stop proxy".into());
}
// Wait a bit for the process to exit
sleep(Duration::from_millis(500)).await;
// Verify proxy is stopped (connection should fail)
match TcpStream::connect(("127.0.0.1", local_port)).await {
Ok(_) => return Err("Proxy should be stopped but is still listening".into()),
Err(_) => println!("Proxy successfully stopped"),
if !wait_for_port_closed(local_port, Duration::from_secs(10)).await {
return Err("Proxy should be stopped but is still listening".into());
}
println!("Proxy successfully stopped");
Ok(())
}
@@ -1785,6 +1769,39 @@ impl Drop for StubBrowser {
}
}
/// Wait for a port to start accepting connections.
///
/// `proxy start` returns once the worker is spawned, not once it has bound its
/// listener, so a fixed sleep is a bet on how fast the runner is. Poll instead.
async fn wait_for_port_open(port: u16, timeout: Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
while std::time::Instant::now() < deadline {
if TcpStream::connect(("127.0.0.1", port)).await.is_ok() {
return true;
}
sleep(Duration::from_millis(100)).await;
}
false
}
/// Wait for a listening port to stop accepting connections.
///
/// `proxy stop` returns once the worker has been told to exit, not once it has
/// actually gone, so the listener can outlive the command by however long the
/// process takes to unwind. That gap is invisible on an idle laptop and lands
/// squarely on a loaded CI runner, so poll to a deadline rather than sleeping a
/// fixed amount and hoping. Returns false if it is still accepting at the end.
async fn wait_for_port_closed(port: u16, timeout: Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
while std::time::Instant::now() < deadline {
if TcpStream::connect(("127.0.0.1", port)).await.is_err() {
return true;
}
sleep(Duration::from_millis(100)).await;
}
false
}
/// Wait for a worker to remove its own config, which it does immediately before
/// exiting. Returns false if it is still there when the deadline passes.
async fn wait_for_worker_exit(proxy_id: &str, timeout: Duration) -> bool {
+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}
+420 -167
View File
@@ -11,6 +11,7 @@ import {
} from "@tanstack/react-table";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { open } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaChrome } from "react-icons/fa";
@@ -19,6 +20,8 @@ import {
LuChevronDown,
LuChevronUp,
LuExternalLink,
LuFolderOpen,
LuLink,
LuPencil,
LuPuzzle,
LuRefreshCw,
@@ -82,6 +85,18 @@ import { RippleButton } from "./ui/ripple";
type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
/** A payload staged in the UI, before it is handed to the backend. */
type PendingSource =
| { kind: "archive"; fileName: string; data: number[] }
| { kind: "folder"; path: string };
const ARCHIVE_EXTENSIONS = [".crx", ".zip"];
function pathBaseName(path: string): string {
const segments = path.split(/[/\\]/).filter(Boolean);
return segments[segments.length - 1] ?? path;
}
function getSyncStatusDot(
item: { sync_enabled?: boolean; last_sync?: number },
liveStatus: SyncStatus | undefined,
@@ -148,14 +163,13 @@ export function ExtensionManagementDialog({
const [extensionGroups, setExtensionGroups] = useState<ExtensionGroup[]>([]);
const [isLoading, setIsLoading] = useState(false);
// Extension upload state
// Extension import state
const [isUploading, setIsUploading] = useState(false);
const [extensionName, setExtensionName] = useState("");
const [showUploadForm, setShowUploadForm] = useState(false);
const [pendingFile, setPendingFile] = useState<{
name: string;
data: number[];
} | null>(null);
const [pendingSource, setPendingSource] = useState<PendingSource | null>(
null,
);
const [linkFolder, setLinkFolder] = useState(false);
// Group state
const [showCreateGroup, setShowCreateGroup] = useState(false);
@@ -192,10 +206,9 @@ export function ExtensionManagementDialog({
null,
);
const [editExtensionName, setEditExtensionName] = useState("");
const [pendingUpdateFile, setPendingUpdateFile] = useState<{
name: string;
data: number[];
} | null>(null);
const [pendingUpdateSource, setPendingUpdateSource] =
useState<PendingSource | null>(null);
const [editLinkFolder, setEditLinkFolder] = useState(false);
// Extension icons
const [extensionIcons, setExtensionIcons] = useState<Record<string, string>>(
@@ -295,6 +308,30 @@ export function ExtensionManagementDialog({
};
}, []);
/** Structured backend codes win; anything else falls back to a local message
* so the user never sees a raw Rust string. */
const showActionError = useCallback(
(err: unknown, fallback: string) => {
showErrorToast(
parseBackendError(err) ? translateBackendError(t, err) : fallback,
);
},
[t],
);
const resetImportForm = useCallback(() => {
setPendingSource(null);
setExtensionName("");
setLinkFolder(false);
}, []);
const closeEditExtension = useCallback(() => {
setEditingExtension(null);
setEditExtensionName("");
setPendingUpdateSource(null);
setEditLinkFolder(false);
}, []);
const handleToggleExtSync = useCallback(
async (ext: Extension) => {
setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: true }));
@@ -310,16 +347,12 @@ export function ExtensionManagementDialog({
);
void loadData();
} catch (err) {
showErrorToast(
parseBackendError(err)
? translateBackendError(t, err)
: t("proxies.management.updateSyncFailed"),
);
showActionError(err, t("proxies.management.updateSyncFailed"));
} finally {
setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: false }));
}
},
[loadData, t],
[loadData, showActionError, t],
);
const handleToggleGroupSync = useCallback(
@@ -337,119 +370,178 @@ export function ExtensionManagementDialog({
);
void loadData();
} catch (err) {
showErrorToast(
parseBackendError(err)
? translateBackendError(t, err)
: t("proxies.management.updateSyncFailed"),
);
showActionError(err, t("proxies.management.updateSyncFailed"));
} finally {
setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: false }));
}
},
[loadData, t],
[loadData, showActionError, t],
);
const handleUpdateExtension = useCallback(async () => {
if (!editingExtension || !editExtensionName.trim()) return;
try {
await invoke("update_extension", {
extensionId: editingExtension.id,
name: editExtensionName.trim(),
fileName: pendingUpdateFile?.name ?? null,
fileData: pendingUpdateFile?.data ?? null,
});
if (pendingUpdateSource?.kind === "folder") {
await invoke("update_extension_from_path", {
extensionId: editingExtension.id,
name: editExtensionName.trim(),
path: pendingUpdateSource.path,
link: editLinkFolder,
});
} else {
await invoke("update_extension", {
extensionId: editingExtension.id,
name: editExtensionName.trim(),
fileName: pendingUpdateSource?.fileName ?? null,
fileData: pendingUpdateSource?.data ?? null,
});
}
showSuccessToast(t("extensions.updateSuccess"));
setEditingExtension(null);
setEditExtensionName("");
setPendingUpdateFile(null);
closeEditExtension();
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showActionError(err, t("extensions.updateFailed"));
}
}, [editingExtension, editExtensionName, pendingUpdateFile, loadData, t]);
}, [
editingExtension,
editExtensionName,
pendingUpdateSource,
editLinkFolder,
closeEditExtension,
loadData,
showActionError,
t,
]);
const handleEditFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const validExtensions = [".xpi", ".crx", ".zip"];
const isValid = validExtensions.some((ext) =>
/** Reads a picked archive into memory, shared by the import and the replace
* flows. Resolves to null when the file is rejected or unreadable. */
const readArchiveFile = useCallback(
(file: File): Promise<PendingSource | null> => {
const isValid = ARCHIVE_EXTENSIONS.some((ext) =>
file.name.toLowerCase().endsWith(ext),
);
if (!isValid) {
showErrorToast(t("extensions.invalidFileType"));
return;
return Promise.resolve(null);
}
const reader = new FileReader();
reader.onload = (event) => {
const arrayBuffer = event.target?.result as ArrayBuffer;
const data = Array.from(new Uint8Array(arrayBuffer));
setPendingUpdateFile({ name: file.name, data });
};
reader.readAsArrayBuffer(file);
e.target.value = "";
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (event) => {
const arrayBuffer = event.target?.result as ArrayBuffer;
resolve({
kind: "archive",
fileName: file.name,
data: Array.from(new Uint8Array(arrayBuffer)),
});
};
reader.onerror = () => {
showErrorToast(t("extensions.readError"));
resolve(null);
};
reader.readAsArrayBuffer(file);
});
},
[t],
);
const handleEditFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
void readArchiveFile(file).then((source) => {
if (!source) return;
setPendingUpdateSource(source);
setEditLinkFolder(false);
});
},
[readArchiveFile],
);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
const validExtensions = [".xpi", ".crx", ".zip"];
const isValid = validExtensions.some((ext) =>
file.name.toLowerCase().endsWith(ext),
);
if (!isValid) {
showErrorToast(t("extensions.invalidFileType"));
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const arrayBuffer = event.target?.result as ArrayBuffer;
const data = Array.from(new Uint8Array(arrayBuffer));
const baseName = file.name
.replace(/\.(xpi|crx|zip)$/i, "")
.replace(/[-_]/g, " ");
setExtensionName(baseName);
setPendingFile({ name: file.name, data });
setShowUploadForm(true);
};
reader.onerror = () => {
showErrorToast(t("extensions.readError"));
};
reader.readAsArrayBuffer(file);
// Reset input
e.target.value = "";
void readArchiveFile(file).then((source) => {
if (!source) return;
setExtensionName(
file.name.replace(/\.(crx|zip)$/i, "").replace(/[-_]/g, " "),
);
setLinkFolder(false);
setPendingSource(source);
});
},
[t],
[readArchiveFile],
);
/** Native directory picker, the "Load unpacked" entry point. */
const pickExtensionFolder = useCallback(async (): Promise<string | null> => {
try {
const selected = await open({
directory: true,
multiple: false,
title: t("extensions.selectFolderTitle"),
});
return typeof selected === "string" ? selected : null;
} catch (err) {
console.error("Failed to open folder dialog:", err);
showErrorToast(t("importProfile.folderDialogFailed"));
return null;
}
}, [t]);
const handleLoadUnpacked = useCallback(async () => {
const folder = await pickExtensionFolder();
if (!folder) return;
setExtensionName(pathBaseName(folder).replace(/[-_]/g, " "));
setLinkFolder(false);
setPendingSource({ kind: "folder", path: folder });
}, [pickExtensionFolder]);
const handleEditFolderSelect = useCallback(async () => {
const folder = await pickExtensionFolder();
if (!folder) return;
setPendingUpdateSource({ kind: "folder", path: folder });
}, [pickExtensionFolder]);
const handleUpload = useCallback(async () => {
if (!pendingFile || !extensionName.trim()) return;
if (!pendingSource || !extensionName.trim()) return;
setIsUploading(true);
try {
await invoke("add_extension", {
name: extensionName.trim(),
fileName: pendingFile.name,
fileData: pendingFile.data,
});
if (pendingSource.kind === "folder") {
await invoke("add_unpacked_extension", {
name: extensionName.trim(),
path: pendingSource.path,
link: linkFolder,
});
} else {
await invoke("add_extension", {
name: extensionName.trim(),
fileName: pendingSource.fileName,
fileData: pendingSource.data,
});
}
showSuccessToast(t("extensions.uploadSuccess"));
setShowUploadForm(false);
setPendingFile(null);
setExtensionName("");
resetImportForm();
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showActionError(err, t("extensions.uploadFailed"));
} finally {
setIsUploading(false);
}
}, [pendingFile, extensionName, loadData, t]);
}, [
pendingSource,
extensionName,
linkFolder,
resetImportForm,
loadData,
showActionError,
t,
]);
const handleDeleteExtension = useCallback(async () => {
if (!extensionToDelete) return;
@@ -460,11 +552,11 @@ export function ExtensionManagementDialog({
setExtensionToDelete(null);
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showActionError(err, t("extensions.deleteFailed"));
} finally {
setIsDeleting(false);
}
}, [extensionToDelete, loadData, t]);
}, [extensionToDelete, loadData, showActionError, t]);
const handleCreateGroup = useCallback(async () => {
if (!newGroupName.trim()) return;
@@ -475,9 +567,9 @@ export function ExtensionManagementDialog({
setNewGroupName("");
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showActionError(err, t("extensions.groupCreateFailed"));
}
}, [newGroupName, loadData, t]);
}, [newGroupName, loadData, showActionError, t]);
const handleSaveGroupEdits = useCallback(async () => {
if (!editingGroup || !editGroupName.trim()) return;
@@ -518,9 +610,16 @@ export function ExtensionManagementDialog({
setEditGroupExtensionIds([]);
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showActionError(err, t("extensions.groupUpdateFailed"));
}
}, [editingGroup, editGroupName, editGroupExtensionIds, loadData, t]);
}, [
editingGroup,
editGroupName,
editGroupExtensionIds,
loadData,
showActionError,
t,
]);
const handleDeleteGroup = useCallback(async () => {
if (!groupToDelete) return;
@@ -531,11 +630,11 @@ export function ExtensionManagementDialog({
setGroupToDelete(null);
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showActionError(err, t("extensions.groupDeleteFailed"));
} finally {
setIsDeleting(false);
}
}, [groupToDelete, loadData, t]);
}, [groupToDelete, loadData, showActionError, t]);
const selectedExtensions = useMemo(
() => extensions.filter((ext) => extRowSelection[ext.id]),
@@ -561,11 +660,11 @@ export function ExtensionManagementDialog({
setExtRowSelection({});
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showActionError(err, t("extensions.deleteFailed"));
} finally {
setIsDeleting(false);
}
}, [selectedExtensions, loadData, t]);
}, [selectedExtensions, loadData, showActionError, t]);
const handleBulkDeleteGroups = useCallback(async () => {
if (selectedGroups.length === 0) return;
@@ -581,18 +680,27 @@ export function ExtensionManagementDialog({
setGroupRowSelection({});
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showActionError(err, t("extensions.groupDeleteFailed"));
} finally {
setIsDeleting(false);
}
}, [selectedGroups, loadData, t]);
}, [selectedGroups, loadData, showActionError, t]);
const handleBulkToggleExtSync = useCallback(async () => {
if (selectedExtensions.length === 0) return;
const allOn = selectedExtensions.every((e) => e.sync_enabled);
const targetEnabled = !allOn;
// A linked extension has no payload to upload, so enabling sync on one is
// refused by the backend. Skip them instead of failing the whole batch.
const targets = targetEnabled
? selectedExtensions.filter((ext) => !ext.linked_path)
: selectedExtensions;
if (targets.length === 0) {
showErrorToast(t("extensions.linkedNoSync"));
return;
}
const results = await Promise.allSettled(
selectedExtensions.map((ext) =>
targets.map((ext) =>
invoke("set_extension_sync_enabled", {
extensionId: ext.id,
enabled: targetEnabled,
@@ -603,10 +711,9 @@ export function ExtensionManagementDialog({
| PromiseRejectedResult
| undefined;
if (firstRejection) {
showErrorToast(
parseBackendError(firstRejection.reason)
? translateBackendError(t, firstRejection.reason)
: t("proxies.management.updateSyncFailed"),
showActionError(
firstRejection.reason,
t("proxies.management.updateSyncFailed"),
);
} else {
showSuccessToast(
@@ -616,7 +723,7 @@ export function ExtensionManagementDialog({
);
}
void loadData();
}, [selectedExtensions, loadData, t]);
}, [selectedExtensions, loadData, showActionError, t]);
const handleBulkToggleGroupSync = useCallback(async () => {
if (selectedGroups.length === 0) return;
@@ -634,10 +741,9 @@ export function ExtensionManagementDialog({
| PromiseRejectedResult
| undefined;
if (firstRejection) {
showErrorToast(
parseBackendError(firstRejection.reason)
? translateBackendError(t, firstRejection.reason)
: t("proxies.management.updateSyncFailed"),
showActionError(
firstRejection.reason,
t("proxies.management.updateSyncFailed"),
);
} else {
showSuccessToast(
@@ -647,7 +753,7 @@ export function ExtensionManagementDialog({
);
}
void loadData();
}, [selectedGroups, loadData, t]);
}, [selectedGroups, loadData, showActionError, t]);
const renderCompatIcons = useCallback(
(compat: string[]) => {
@@ -691,6 +797,42 @@ export function ExtensionManagementDialog({
[extensionIcons],
);
/** What the extension actually is: a stored archive, a folder packed into
* the store, or a folder loaded in place from the user's disk. */
const renderSource = useCallback(
(ext: Extension) => {
if (ext.linked_path) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
<LuLink className="size-3 shrink-0" />
<span className="truncate">
{t("extensions.source.linked")}
</span>
</span>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs break-all">
{t("extensions.source.linkedTooltip", {
path: ext.linked_path,
})}
</p>
</TooltipContent>
</Tooltip>
);
}
return (
<span className="block min-w-0 truncate text-xs text-muted-foreground">
{ext.source_kind === "unpacked"
? t("extensions.source.unpacked")
: t("extensions.source.archive")}
</span>
);
},
[t],
);
const MAX_VISIBLE_ICONS = 3;
const extensionColumns = useMemo<ColumnDef<Extension>[]>(
@@ -762,6 +904,13 @@ export function ExtensionManagementDialog({
cell: ({ row }) =>
renderCompatIcons(row.original.browser_compatibility),
},
{
id: "source",
size: 128,
enableSorting: false,
header: () => null,
cell: ({ row }) => renderSource(row.original),
},
{
id: "sync",
size: 88,
@@ -770,6 +919,7 @@ export function ExtensionManagementDialog({
cell: ({ row }) => {
const ext = row.original;
const syncDot = getSyncStatusDot(ext, extSyncStatus[ext.id], t);
const isLinked = Boolean(ext.linked_path);
return (
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
@@ -790,15 +940,17 @@ export function ExtensionManagementDialog({
<AnimatedSwitch
checked={ext.sync_enabled}
onCheckedChange={() => void handleToggleExtSync(ext)}
disabled={isTogglingExtSync[ext.id]}
disabled={isLinked || isTogglingExtSync[ext.id]}
/>
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{ext.sync_enabled
? t("syncTooltips.disable")
: t("syncTooltips.enable")}
{isLinked
? t("extensions.linkedNoSync")
: ext.sync_enabled
? t("syncTooltips.disable")
: t("syncTooltips.enable")}
</p>
</TooltipContent>
</Tooltip>
@@ -824,7 +976,8 @@ export function ExtensionManagementDialog({
onClick={() => {
setEditingExtension(ext);
setEditExtensionName(ext.name);
setPendingUpdateFile(null);
setPendingUpdateSource(null);
setEditLinkFolder(Boolean(ext.linked_path));
}}
>
<LuPencil className="size-3.5" />
@@ -859,6 +1012,7 @@ export function ExtensionManagementDialog({
handleToggleExtSync,
renderExtensionIcon,
renderCompatIcons,
renderSource,
],
);
@@ -1160,25 +1314,48 @@ export function ExtensionManagementDialog({
</AnimatedTabsList>
<div className="flex items-center gap-2">
{activeTab === "extensions" && (
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
size="sm"
variant="outline"
disabled={limitedMode}
onClick={() =>
document.getElementById("ext-file-input")?.click()
}
aria-label={t("extensions.upload")}
>
<LuUpload className="size-4" />
<span className="hidden @2xl:inline">
{t("extensions.upload")}
</span>
</RippleButton>
</TooltipTrigger>
<TooltipContent>{t("extensions.upload")}</TooltipContent>
</Tooltip>
<>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
size="sm"
variant="outline"
disabled={limitedMode}
onClick={() =>
document.getElementById("ext-file-input")?.click()
}
aria-label={t("extensions.upload")}
>
<LuUpload className="size-4" />
<span className="hidden @2xl:inline">
{t("extensions.upload")}
</span>
</RippleButton>
</TooltipTrigger>
<TooltipContent>
{t("extensions.upload")}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
size="sm"
variant="outline"
disabled={limitedMode}
onClick={() => void handleLoadUnpacked()}
aria-label={t("extensions.loadUnpacked")}
>
<LuFolderOpen className="size-4" />
<span className="hidden @2xl:inline">
{t("extensions.loadUnpacked")}
</span>
</RippleButton>
</TooltipTrigger>
<TooltipContent>
{t("extensions.loadUnpackedTooltip")}
</TooltipContent>
</Tooltip>
</>
)}
{activeTab === "groups" && (
<Tooltip>
@@ -1216,21 +1393,51 @@ export function ExtensionManagementDialog({
<Input
id="ext-file-input"
type="file"
accept=".xpi,.crx,.zip"
accept=".crx,.zip"
className="hidden"
onChange={handleFileSelect}
disabled={limitedMode}
/>
{/* Upload form */}
{showUploadForm && pendingFile && (
{/* Import form */}
{pendingSource && (
<div className="space-y-3 rounded-md border p-3">
<div className="text-sm text-muted-foreground">
{t("extensions.selectedFile")}:{" "}
<span className="font-medium text-foreground">
{pendingFile.name}
{pendingSource.kind === "folder"
? t("extensions.selectedFolder")
: t("extensions.selectedFile")}
:{" "}
<span className="font-medium break-all text-foreground">
{pendingSource.kind === "folder"
? pendingSource.path
: pendingSource.fileName}
</span>
</div>
{pendingSource.kind === "folder" && (
<div className="flex items-start gap-2">
<Checkbox
id="ext-link-folder"
checked={linkFolder}
onCheckedChange={(value) => {
setLinkFolder(value === true);
}}
className="mt-0.5"
/>
<div className="space-y-0.5">
<Label
htmlFor="ext-link-folder"
className="text-sm font-normal"
>
{t("extensions.linkFolder")}
</Label>
<p className="text-xs text-muted-foreground">
{linkFolder
? t("extensions.linkFolderOn")
: t("extensions.linkFolderOff")}
</p>
</div>
</div>
)}
<div className="flex gap-2">
<Input
value={extensionName}
@@ -1252,11 +1459,7 @@ export function ExtensionManagementDialog({
<Button
size="sm"
variant="outline"
onClick={() => {
setShowUploadForm(false);
setPendingFile(null);
setExtensionName("");
}}
onClick={resetImportForm}
>
{t("common.buttons.cancel")}
</Button>
@@ -1611,11 +1814,7 @@ export function ExtensionManagementDialog({
<Dialog
open={editingExtension !== null}
onOpenChange={(open) => {
if (!open) {
setEditingExtension(null);
setEditExtensionName("");
setPendingUpdateFile(null);
}
if (!open) closeEditExtension();
}}
>
<DialogContent className="flex max-h-[90vh] max-w-lg flex-col">
@@ -1684,9 +1883,35 @@ export function ExtensionManagementDialog({
)}
</div>
<span className="text-muted-foreground">
{t("common.labels.type")}
{t("extensions.source.label")}
</span>
<span>.{editingExtension.file_type}</span>
<span>
{editingExtension.linked_path
? t("extensions.source.linked")
: editingExtension.source_kind === "unpacked"
? t("extensions.source.unpacked")
: t("extensions.source.archive")}
</span>
{editingExtension.linked_path ? (
<>
<span className="text-muted-foreground">
{t("extensions.source.folderLabel")}
</span>
<span className="break-all">
{editingExtension.linked_path}
</span>
<p className="col-span-2 text-xs text-muted-foreground">
{t("extensions.linkFolderOn")}
</p>
</>
) : (
<>
<span className="text-muted-foreground">
{t("common.labels.type")}
</span>
<span>.{editingExtension.file_type}</span>
</>
)}
{editingExtension.homepage_url && (
<>
<span className="text-muted-foreground">
@@ -1716,10 +1941,10 @@ export function ExtensionManagementDialog({
</div>
</div>
{/* Re-upload */}
{/* Replace the payload with another archive or folder */}
<div className="space-y-2">
<Label>{t("extensions.reupload")}</Label>
<div className="flex items-center gap-2">
<Label>{t("extensions.replaceSource")}</Label>
<div className="flex flex-wrap items-center gap-2">
<RippleButton
size="sm"
variant="outline"
@@ -1733,30 +1958,58 @@ export function ExtensionManagementDialog({
<input
id="ext-edit-file-input"
type="file"
accept=".xpi,.crx,.zip"
accept=".crx,.zip"
className="hidden"
onChange={handleEditFileSelect}
/>
{pendingUpdateFile && (
<RippleButton
size="sm"
variant="outline"
onClick={() => void handleEditFolderSelect()}
>
<LuFolderOpen className="mr-1 size-3" />
{t("extensions.selectFolder")}
</RippleButton>
{pendingUpdateSource && (
<span className="max-w-[200px] truncate text-xs text-muted-foreground">
{pendingUpdateFile.name}
{pendingUpdateSource.kind === "folder"
? pendingUpdateSource.path
: pendingUpdateSource.fileName}
</span>
)}
</div>
{pendingUpdateSource?.kind === "folder" && (
<div className="flex items-start gap-2 pt-1">
<Checkbox
id="ext-edit-link-folder"
checked={editLinkFolder}
onCheckedChange={(value) => {
setEditLinkFolder(value === true);
}}
className="mt-0.5"
/>
<div className="space-y-0.5">
<Label
htmlFor="ext-edit-link-folder"
className="text-sm font-normal"
>
{t("extensions.linkFolder")}
</Label>
<p className="text-xs text-muted-foreground">
{editLinkFolder
? t("extensions.linkFolderOn")
: t("extensions.linkFolderOff")}
</p>
</div>
</div>
)}
</div>
</div>
)}
</ScrollArea>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setEditingExtension(null);
setEditExtensionName("");
setPendingUpdateFile(null);
}}
>
<Button variant="outline" onClick={closeEditExtension}>
{t("common.buttons.cancel")}
</Button>
<RippleButton
+233 -89
View File
@@ -53,10 +53,88 @@ import type {
ImportProfileItem,
ProfileImportBatchResult,
ProfileImportProgress,
ProfileImportReport,
WayfernConfig,
} from "@/types";
import { RippleButton } from "./ui/ripple";
/**
* What an import actually carried, and what it could not.
*
* The counts matter more than they look: an import that reports zero of
* everything is the exact symptom of the bug where copied data landed where
* the browser never reads it, and it used to be indistinguishable from success.
*/
function ImportReportSummary({ report }: { report: ProfileImportReport }) {
const { t } = useTranslation();
// Label-then-value rather than "{{count}} cookies": it keeps the row scannable
// and sidesteps needing correct plural forms in ten languages.
const carried = (
[
["importProfile.reportCookies", report.cookies_migrated],
["importProfile.reportPasswords", report.passwords_migrated],
["importProfile.reportAutofill", report.payment_methods_migrated],
["importProfile.reportExtensions", report.extensions_migrated],
["importProfile.reportHistory", report.history_entries],
["importProfile.reportBookmarks", report.bookmarks],
["importProfile.reportLocalStorage", report.local_storage_origins],
] as const
)
.filter(([, count]) => count > 0)
.map(([key, count]) => `${t(key)} ${count.toLocaleString()}`);
const unrecoverable =
report.cookies_unrecoverable +
report.passwords_unrecoverable +
report.payment_methods_unrecoverable;
return (
<div className="mt-0.5 space-y-0.5 pl-1 text-xs text-muted-foreground">
<p>
{carried.length > 0
? carried.join(" · ")
: t("importProfile.reportNothingCarried")}
</p>
{unrecoverable > 0 && (
<p>
{t("importProfile.reportUnrecoverable", { count: unrecoverable })}
</p>
)}
{report.warnings.map((code) => (
<p key={code} className="text-warning-text">
{t(`importProfile.warnings.${code}`)}
</p>
))}
</div>
);
}
/**
* Fold a retry's results back into the batch it came from.
*
* A retry only resubmits the items that failed, so the previous batch is still
* authoritative for every other row. Replacing it wholesale would make the
* successful imports disappear from the summary.
*/
function mergeImportResults(
previous: ProfileImportBatchResult,
retry: ProfileImportBatchResult,
): ProfileImportBatchResult {
const byPath = new Map(retry.results.map((item) => [item.source_path, item]));
const results = previous.results.map(
(item) => byPath.get(item.source_path) ?? item,
);
const count = (status: string) =>
results.filter((item) => item.status === status).length;
return {
imported_count: count("imported"),
skipped_count: count("skipped"),
failed_count: count("failed"),
results,
};
}
interface ImportProfileDialogProps {
isOpen: boolean;
onClose: () => void;
@@ -283,69 +361,99 @@ export function ImportProfileDialog({
}
};
const handleImport = useCallback(async () => {
if (selectedProfiles.length === 0) {
toast.error(t("importProfile.selectAtLeastOne"));
return;
}
if (
selectedProfiles.some((p) => !(profileNames[p.path] ?? p.name).trim())
) {
toast.error(t("importProfile.emptyNames"));
return;
}
const items: ImportProfileItem[] = selectedProfiles.map((p, index) => ({
source_path: p.path,
browser_type: p.browser,
new_profile_name: (profileNames[p.path] ?? p.name).trim(),
proxy_id: proxyIdForIndex(index),
vpn_id: vpnAssignment === "none" ? null : vpnAssignment,
}));
setCurrentStep("importing");
setIsImporting(true);
setProgress(null);
setResult(null);
try {
const batchResult = await invoke<ProfileImportBatchResult>(
"import_browser_profiles",
{
items,
groupId: selectedGroupId === "none" ? null : selectedGroupId,
duplicateStrategy: duplicateStrategy,
wayfernConfig,
},
);
setResult(batchResult);
toast.success(
t("importProfile.resultsSummary", {
imported: batchResult.imported_count,
skipped: batchResult.skipped_count,
failed: batchResult.failed_count,
}),
);
if (batchResult.imported_count > 0 && !reducedMotion) {
fireSprinkleConfetti();
const handleImport = useCallback(
async (allowRunning = false, retryPaths?: ReadonlySet<string>) => {
if (selectedProfiles.length === 0) {
toast.error(t("importProfile.selectAtLeastOne"));
return;
}
} catch (error) {
console.error("Failed to import profiles:", error);
toast.error(translateBackendError(t, error));
setCurrentStep("configure");
} finally {
setIsImporting(false);
}
}, [
selectedProfiles,
profileNames,
proxyIdForIndex,
vpnAssignment,
selectedGroupId,
duplicateStrategy,
wayfernConfig,
reducedMotion,
t,
]);
if (
selectedProfiles.some((p) => !(profileNames[p.path] ?? p.name).trim())
) {
toast.error(t("importProfile.emptyNames"));
return;
}
// Filter AFTER the map, so a retry keeps the proxy each profile was
// originally assigned by the index-based round-robin.
const items: ImportProfileItem[] = selectedProfiles
.map((p, index) => ({
source_path: p.path,
browser_type: p.browser,
new_profile_name: (profileNames[p.path] ?? p.name).trim(),
proxy_id: proxyIdForIndex(index),
vpn_id: vpnAssignment === "none" ? null : vpnAssignment,
allow_running: allowRunning,
}))
.filter((item) => !retryPaths || retryPaths.has(item.source_path));
if (items.length === 0) {
return;
}
setCurrentStep("importing");
setIsImporting(true);
setProgress(null);
// A retry covers only the failed subset, so the earlier results are still
// the truth for everything else and must not be thrown away.
const previous = retryPaths ? result : null;
setResult(null);
try {
const batchResult = await invoke<ProfileImportBatchResult>(
"import_browser_profiles",
{
items,
groupId: selectedGroupId === "none" ? null : selectedGroupId,
duplicateStrategy: duplicateStrategy,
wayfernConfig,
},
);
setResult(
previous ? mergeImportResults(previous, batchResult) : batchResult,
);
toast.success(
t("importProfile.resultsSummary", {
imported: batchResult.imported_count,
skipped: batchResult.skipped_count,
failed: batchResult.failed_count,
}),
);
if (batchResult.imported_count > 0 && !reducedMotion) {
fireSprinkleConfetti();
}
} catch (error) {
console.error("Failed to import profiles:", error);
toast.error(translateBackendError(t, error));
setCurrentStep("configure");
} finally {
setIsImporting(false);
}
},
[
selectedProfiles,
profileNames,
proxyIdForIndex,
vpnAssignment,
selectedGroupId,
duplicateStrategy,
wayfernConfig,
reducedMotion,
result,
t,
],
);
// A source browser that is still running is the one failure the user can fix
// without starting over, so offer the override right where it happened.
const hasRunningBrowserFailure = useMemo(
() =>
(result?.results ?? []).some(
(item) =>
item.status === "failed" &&
item.error?.includes("IMPORT_SOURCE_BROWSER_RUNNING"),
),
[result],
);
const handleClose = () => {
void cleanupExtractedDir(extractedDir);
@@ -840,38 +948,74 @@ export function ImportProfileDialog({
</h3>
<div className="max-h-64 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
{result.results.map((item) => (
<div
key={item.source_path}
className="flex items-center gap-2 p-1 text-sm"
>
<span
className={cn(
"shrink-0 text-xs font-medium",
item.status === "imported" && "text-success-text",
item.status === "skipped" &&
"text-muted-foreground",
item.status === "failed" &&
"text-destructive-text",
)}
>
{item.status === "imported" &&
t("importProfile.statusImported")}
{item.status === "skipped" &&
t("importProfile.statusSkipped")}
{item.status === "failed" &&
t("importProfile.statusFailed")}
</span>
<span className="min-w-0 flex-1 truncate">
{item.name || item.source_path}
</span>
{item.error && (
<span className="min-w-0 flex-1 truncate text-xs text-destructive-text">
{translateBackendError(t, new Error(item.error))}
<div key={item.source_path} className="p-1 text-sm">
<div className="flex items-center gap-2">
<span
className={cn(
"shrink-0 text-xs font-medium",
item.status === "imported" &&
"text-success-text",
item.status === "skipped" &&
"text-muted-foreground",
item.status === "failed" &&
"text-destructive-text",
)}
>
{item.status === "imported" &&
t("importProfile.statusImported")}
{item.status === "skipped" &&
t("importProfile.statusSkipped")}
{item.status === "failed" &&
t("importProfile.statusFailed")}
</span>
<span className="min-w-0 flex-1 truncate">
{item.name || item.source_path}
</span>
{item.error && (
<span className="min-w-0 flex-1 truncate text-xs text-destructive-text">
{translateBackendError(
t,
new Error(item.error),
)}
</span>
)}
</div>
{item.report && (
<ImportReportSummary report={item.report} />
)}
</div>
))}
</div>
{hasRunningBrowserFailure && (
<Alert>
<AlertDescription className="space-y-2">
<p>{t("importProfile.closeSourceBrowserHint")}</p>
<Button
variant="outline"
size="sm"
onClick={() => {
void handleImport(
true,
new Set(
result.results
.filter(
(item) =>
item.status === "failed" &&
item.error?.includes(
"IMPORT_SOURCE_BROWSER_RUNNING",
),
)
.map((item) => item.source_path),
),
);
}}
>
{t("importProfile.importAnyway")}
</Button>
</AlertDescription>
</Alert>
)}
</div>
)}
</div>
+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")}
+83 -29
View File
@@ -29,6 +29,7 @@ import {
LuSettings,
LuShield,
LuShieldCheck,
LuTimer,
LuTrash2,
LuUpload,
LuUsers,
@@ -735,6 +736,13 @@ function ProfileInfoLayout({
[visibleActions],
);
// An ephemeral profile is discarded when the browser closes, so it has
// nowhere to keep cookies, extensions or a synced copy. The sections were
// hidden outright, which left no way to discover that and read as the app
// being broken or the plan lacking the feature. Keep them listed and explain.
const isEphemeral = profile.ephemeral === true;
const isWayfernProfile = profile.browser === "wayfern";
const deleteAction = findAction("delete");
const fingerprintAction = findAction("fingerprint");
const cookiesManageAction = findAction("cookiesManage");
@@ -814,20 +822,20 @@ function ProfileInfoLayout({
cookieCount !== null && cookieCount > 0
? cookieCount.toLocaleString()
: undefined,
hidden: !cookiesAction,
hidden: !cookiesAction && !(isEphemeral && isWayfernProfile),
},
{
id: "extensions",
icon: <LuPuzzle className="size-3.5" />,
label: t("profileInfo.sections.extensions"),
badge: extensionGroupName ?? undefined,
hidden: !extensionAction,
hidden: !extensionAction && !isEphemeral,
},
{
id: "sync",
icon: <LuRefreshCw className="size-3.5" />,
label: t("profileInfo.sections.sync"),
hidden: !syncAction,
hidden: !syncAction && !isEphemeral,
},
{
id: "automation",
@@ -1073,34 +1081,55 @@ function ProfileInfoLayout({
/>
)}
{section === "cookies" && (
<CookiesSectionInline
profile={profile}
isRunning={isRunning}
isDisabled={isDisabled}
onCopyCookies={cookiesCopyAction?.onClick}
onImportCookies={cookiesManageAction?.onClick}
t={t}
/>
)}
{section === "cookies" &&
(isEphemeral ? (
<EphemeralSectionNotice
title={t("profileInfo.sections.cookies")}
description={t("profileInfo.ephemeral.cookiesUnavailable")}
t={t}
/>
) : (
<CookiesSectionInline
profile={profile}
isRunning={isRunning}
isDisabled={isDisabled}
onCopyCookies={cookiesCopyAction?.onClick}
onImportCookies={cookiesManageAction?.onClick}
t={t}
/>
))}
{section === "extensions" && (
<ExtensionsSectionInline
profile={profile}
isDisabled={isDisabled}
t={t}
/>
)}
{section === "extensions" &&
(isEphemeral ? (
<EphemeralSectionNotice
title={t("profileInfo.sections.extensions")}
description={t("profileInfo.ephemeral.extensionsUnavailable")}
t={t}
/>
) : (
<ExtensionsSectionInline
profile={profile}
isDisabled={isDisabled}
t={t}
/>
))}
{section === "sync" && (
<SyncSectionInline
profile={profile}
syncMode={syncMode}
syncStatus={syncStatus}
isDisabled={isDisabled}
t={t}
/>
)}
{section === "sync" &&
(isEphemeral ? (
<EphemeralSectionNotice
title={t("profileInfo.sections.sync")}
description={t("profileInfo.ephemeral.syncUnavailable")}
t={t}
/>
) : (
<SyncSectionInline
profile={profile}
syncMode={syncMode}
syncStatus={syncStatus}
isDisabled={isDisabled}
t={t}
/>
))}
{section === "automation" && (
<LaunchHookEditor profile={profile} t={t} />
@@ -1507,6 +1536,31 @@ function NetworkSectionInline({
);
}
/// Explains why a section has nothing to offer on an ephemeral profile.
/// Mirrors the locked-fingerprint empty state so the two read as one pattern.
function EphemeralSectionNotice({
title,
description,
t,
}: {
title: string;
description: string;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border p-6 text-center">
<LuTimer className="size-4 shrink-0 text-muted-foreground" />
<h3 className="text-sm font-medium text-foreground">{title}</h3>
<p className="max-w-[48ch] text-sm text-pretty text-muted-foreground">
{description}
</p>
<p className="max-w-[48ch] text-xs text-pretty text-muted-foreground">
{t("profileInfo.ephemeral.hint")}
</p>
</div>
);
}
function ExtensionsSectionInline({
profile,
isDisabled,
+48 -1
View File
@@ -23,7 +23,8 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { translateBackendError } from "@/lib/backend-errors";
import type { StoredProxy } from "@/types";
import { pickParsedProxy } from "@/lib/proxy-string";
import type { ProxyParseResult, StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProxyFormData {
@@ -202,6 +203,48 @@ export function ProxyFormDialog({
}
}, [isSubmitting, onClose]);
// Proxies are copied around as one string — `socks5://user:pass@host:1080`,
// `host:1080:user:pass`, and a dozen variants of both — so a paste into any
// one field is almost never meant for that field alone. Hand the clipboard to
// the same Rust parser the import dialog uses and spread the result across
// the form. The default paste is left alone until the answer comes back, so a
// string that isn't a proxy (a hostname, a port) lands where it was dropped.
const handleProxyPaste = useCallback(
(event: React.ClipboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const content = event.clipboardData.getData("text").trim();
if (!content) {
return;
}
// Captured before the browser applies the paste, so a proxy string
// dropped into the empty name field names the proxy after its endpoint
// instead of keeping the raw line.
const nameBeforePaste = form.name.trim();
void invoke<ProxyParseResult[]>("parse_txt_proxies", { content })
.then((results) => {
const parsed = pickParsedProxy(results);
if (!parsed) {
return;
}
setForm((previous) => ({
...previous,
name: nameBeforePaste || `${parsed.host}:${parsed.port}`,
proxy_type: parsed.proxy_type,
host: parsed.host,
port: parsed.port,
username: parsed.username ?? "",
password: parsed.password ?? "",
vless_uri: parsed.vless_uri ?? "",
}));
})
.catch((error: unknown) => {
console.error("Failed to parse pasted proxy:", error);
});
},
[form.name],
);
const isVless = form.proxy_type === "vless";
const vlessEndpoint = isVless ? parseVlessEndpoint(form.vless_uri) : null;
@@ -259,6 +302,7 @@ export function ProxyFormDialog({
onChange={(e) => {
setForm({ ...form, name: e.target.value });
}}
onPaste={handleProxyPaste}
placeholder={t("proxies.form.namePlaceholder")}
disabled={isSubmitting}
/>
@@ -303,6 +347,7 @@ export function ProxyFormDialog({
onChange={(e) => {
setForm({ ...form, vless_uri: e.target.value });
}}
onPaste={handleProxyPaste}
placeholder={t("proxies.form.vlessUriPlaceholder")}
disabled={isSubmitting}
aria-invalid={hasInvalidVlessUri}
@@ -337,6 +382,7 @@ export function ProxyFormDialog({
onChange={(e) => {
setForm({ ...form, host: e.target.value });
}}
onPaste={handleProxyPaste}
placeholder={t("proxies.form.hostPlaceholder")}
disabled={isSubmitting}
/>
@@ -354,6 +400,7 @@ export function ProxyFormDialog({
port: Number.parseInt(e.target.value, 10) || 0,
});
}}
onPaste={handleProxyPaste}
placeholder={t("proxies.form.portPlaceholder")}
min="1"
max="65535"
+7 -25
View File
@@ -20,6 +20,7 @@ import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { StepTransition } from "@/components/ui/step-transition";
import { getCurrentOS } from "@/lib/browser-utils";
import { resolveAmbiguousProxyLine } from "@/lib/proxy-string";
import type {
ParsedProxyLine,
ProxyImportResult,
@@ -265,31 +266,12 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
);
const handleResolveAmbiguous = useCallback(() => {
// Convert ambiguous proxies to parsed based on selected format
const resolved: ParsedProxyLine[] = ambiguousProxies
.filter((p) => p.selectedFormat)
.map((p) => {
const parts = p.line.split(":");
if (p.selectedFormat === "host:port:username:password") {
return {
proxy_type: "http",
host: parts[0],
port: Number.parseInt(parts[1], 10),
username: parts[2],
password: parts[3],
original_line: p.line,
};
}
// username:password:host:port
return {
proxy_type: "http",
host: parts[2],
port: Number.parseInt(parts[3], 10),
username: parts[0],
password: parts[1],
original_line: p.line,
};
});
const resolved = ambiguousProxies.flatMap((p) => {
const parsed = p.selectedFormat
? resolveAmbiguousProxyLine(p.line, p.selectedFormat)
: null;
return parsed ? [parsed] : [];
});
setParsedProxies((prev) => [...prev, ...resolved]);
setStep("preview");
+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 () => {
+55 -15
View File
@@ -80,19 +80,49 @@ export function SyncConfigDialog({
const [connectionStatus, setConnectionStatus] = useState<
"unknown" | "testing" | "connected" | "error"
>("unknown");
const [storageEndpoint, setStorageEndpoint] = useState<string | null>(null);
const hasConfig = Boolean(serverUrl && token);
const testConnection = useCallback(async (url: string) => {
setConnectionStatus("testing");
try {
const healthUrl = `${url.replace(/\/$/, "")}/health`;
const response = await fetch(healthUrl);
setConnectionStatus(response.ok ? "connected" : "error");
} catch {
setConnectionStatus("error");
// `/health` is a bare liveness probe: it answers ok on a server whose storage
// is unreachable or misconfigured, which is how a green "connected" could sit
// next to a sync where every single file failed. `/readyz` checks storage and
// reports the endpoint clients are handed in presigned URLs, so surface that
// too — when transfers fail, it is the value worth checking first.
const probeServer = useCallback(async (url: string) => {
const base = url.replace(/\/$/, "");
const response = await fetch(`${base}/readyz`);
// A server old enough to predate /readyz is still a working server, so
// fall back rather than reporting a healthy setup as broken.
if (response.status === 404) {
const health = await fetch(`${base}/health`);
return { ok: health.ok, storageEndpoint: undefined };
}
if (!response.ok) {
return { ok: false as const, storageEndpoint: undefined };
}
const body = (await response.json()) as {
storageEndpoint?: string;
} | null;
return { ok: true as const, storageEndpoint: body?.storageEndpoint };
}, []);
const testConnection = useCallback(
async (url: string) => {
setConnectionStatus("testing");
try {
const result = await probeServer(url);
setStorageEndpoint(result.storageEndpoint ?? null);
setConnectionStatus(result.ok ? "connected" : "error");
} catch {
setStorageEndpoint(null);
setConnectionStatus("error");
}
},
[probeServer],
);
const loadSettings = useCallback(async () => {
setIsLoading(true);
try {
@@ -142,9 +172,9 @@ export function SyncConfigDialog({
setIsTesting(true);
setConnectionStatus("testing");
try {
const healthUrl = `${serverUrl.replace(/\/$/, "")}/health`;
const response = await fetch(healthUrl);
if (response.ok) {
const result = await probeServer(serverUrl);
setStorageEndpoint(result.storageEndpoint ?? null);
if (result.ok) {
setConnectionStatus("connected");
showSuccessToast(t("sync.config.connectionSuccess"));
} else {
@@ -152,12 +182,13 @@ export function SyncConfigDialog({
showErrorToast(t("sync.config.serverError"));
}
} catch {
setStorageEndpoint(null);
setConnectionStatus("error");
showErrorToast(t("sync.config.connectFailed"));
} finally {
setIsTesting(false);
}
}, [serverUrl, t]);
}, [serverUrl, t, probeServer]);
const handleSave = useCallback(async () => {
setIsSaving(true);
@@ -440,9 +471,18 @@ export function SyncConfigDialog({
</div>
)}
{connectionStatus === "connected" && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="size-2 rounded-full bg-success" />
{t("sync.status.connected")}
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="size-2 rounded-full bg-success" />
{t("sync.status.connected")}
</div>
{storageEndpoint && (
<span className="text-xs text-muted-foreground break-all">
{t("sync.config.storageEndpoint", {
endpoint: storageEndpoint,
})}
</span>
)}
</div>
)}
{connectionStatus === "error" && (
+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(() => {
+16
View File
@@ -1855,10 +1855,18 @@
"name": "ntapi",
"license": "Apache-2.0 OR MIT"
},
{
"name": "num",
"license": "Apache-2.0 OR MIT"
},
{
"name": "num-bigint",
"license": "Apache-2.0 OR MIT"
},
{
"name": "num-complex",
"license": "Apache-2.0 OR MIT"
},
{
"name": "num-conv",
"license": "Apache-2.0 OR MIT"
@@ -1871,6 +1879,10 @@
"name": "num-integer",
"license": "Apache-2.0 OR MIT"
},
{
"name": "num-iter",
"license": "Apache-2.0 OR MIT"
},
{
"name": "num-rational",
"license": "Apache-2.0 OR MIT"
@@ -2407,6 +2419,10 @@
"name": "sealed",
"license": "Apache-2.0 OR MIT"
},
{
"name": "secret-service",
"license": "Apache-2.0 OR MIT"
},
{
"name": "security-framework",
"license": "Apache-2.0 OR MIT"
+80 -14
View File
@@ -635,6 +635,7 @@
"connectionSuccess": "Connection successful!",
"serverError": "Server responded with an error",
"connectFailed": "Failed to connect to server",
"storageEndpoint": "Storage: {{endpoint}}",
"settingsSaved": "Sync settings saved",
"saveFailed": "Failed to save settings",
"disconnected": "Sync disconnected",
@@ -1229,6 +1230,12 @@
"syncing": "Syncing",
"synced": "Synced",
"error": "Error"
},
"ephemeral": {
"cookiesUnavailable": "Ephemeral profiles are discarded when the browser closes, so there are no cookies to manage here.",
"extensionsUnavailable": "Ephemeral profiles are discarded when the browser closes, so extension groups cannot be assigned to them.",
"syncUnavailable": "Ephemeral profiles are discarded when the browser closes, so there is nothing to sync to the cloud.",
"hint": "Create a regular profile if you need this to persist."
}
},
"extensions": {
@@ -1264,7 +1271,7 @@
"deleteConfirmDescription": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"deleteGroupConfirmTitle": "Delete Extension Group",
"deleteGroupConfirmDescription": "Are you sure you want to delete the group \"{{name}}\"? This action cannot be undone.",
"invalidFileType": "Invalid file type. Please upload a .crx, .xpi, or .zip file.",
"invalidFileType": "Invalid file type. Please choose a .crx or .zip file.",
"readError": "Failed to read the extension file.",
"assignTitle": "Assign Extension Group",
"assignDescription": "Assign {{count}} selected profile(s) to an extension group.",
@@ -1272,7 +1279,6 @@
"assignSuccess": "Extension group assigned successfully",
"editExtension": "Edit extension",
"updateSuccess": "Extension updated successfully",
"reupload": "Re-upload",
"version": "Version",
"author": "Author",
"homepage": "Homepage",
@@ -1280,10 +1286,26 @@
"editGroupDescription": "Update the group name and manage which extensions are included.",
"groupExtensions": "Extensions in this group",
"noExtensionsInGroup": "No extensions added yet",
"editExtensionDescription": "Update extension name, view metadata, or re-upload the extension file.",
"editExtensionDescription": "Update the extension name, view its metadata, or replace it with another archive or folder.",
"metadata": "Metadata",
"noMetadata": "No metadata available from manifest.",
"selectFile": "Choose File",
"loadUnpacked": "Load unpacked",
"loadUnpackedTooltip": "Load an extension from a folder containing manifest.json",
"selectFolderTitle": "Select Extension Folder",
"selectedFolder": "Selected folder",
"selectFolder": "Choose Folder",
"linkFolder": "Load in place from this folder",
"linkFolderOff": "The folder is copied into Donut. The extension is portable and syncs to your other devices.",
"linkFolderOn": "Donut loads the extension straight from this folder on every launch. Your edits apply on the next browser start, but the extension stays on this machine and never syncs.",
"replaceSource": "Replace source",
"linkedNoSync": "Linked extensions stay on this machine and can't sync.",
"uploadFailed": "Failed to add extension",
"updateFailed": "Failed to update extension",
"deleteFailed": "Failed to delete extension",
"groupCreateFailed": "Failed to create extension group",
"groupUpdateFailed": "Failed to update extension group",
"groupDeleteFailed": "Failed to delete extension group",
"syncEnabled": "Sync enabled",
"syncDisabled": "Sync disabled",
"syncEnableTooltip": "Enable sync",
@@ -1296,6 +1318,14 @@
"groupsTitle": "Delete extension groups",
"groupsDescription": "Delete {{count}} extension groups? {{names}}",
"confirmButton": "Delete"
},
"source": {
"label": "Source",
"archive": "Archive",
"unpacked": "Unpacked folder",
"linked": "Linked folder",
"folderLabel": "Folder",
"linkedTooltip": "Loaded in place from {{path}}"
}
},
"pro": {
@@ -1467,7 +1497,28 @@
"vpnOptional": "VPN (Optional)",
"noVpn": "No VPN",
"advancedOptions": "Advanced options",
"configureFingerprint": "Configure fingerprint (optional)"
"configureFingerprint": "Configure fingerprint (optional)",
"reportCookies": "Cookies",
"reportPasswords": "Passwords",
"reportAutofill": "Payment methods",
"reportExtensions": "Extensions",
"reportHistory": "History",
"reportBookmarks": "Bookmarks",
"reportLocalStorage": "Site data",
"reportNothingCarried": "No readable data was carried over",
"reportUnrecoverable": "Could not be decrypted: {{count}}",
"closeSourceBrowserHint": "Close the source browser and try again for a complete copy, or import now and accept that site data may be incomplete.",
"importAnyway": "Import anyway",
"warnings": {
"secretsNotMigrated": "Cookies and passwords could not be unlocked, so you will need to sign in again.",
"appBoundEncrypted": "Chrome 127+ on Windows locks cookies to the browser itself; those cookies cannot be migrated by any other app.",
"storeTooOld": "A database was too old for this browser to open and was skipped.",
"storeTooNew": "A database came from a newer browser than this one and was skipped.",
"sourceBrowserRunning": "The source browser was running, so site data may be incomplete.",
"securePreferencesReset": "Protected settings such as the homepage and search engine reset to defaults.",
"extensionsPartial": "Some extensions belonged to the source browser and were not carried over.",
"storeUnreadable": "A database could not be read and was skipped rather than copied damaged."
}
},
"syncTooltips": {
"syncing": "Syncing...",
@@ -1814,6 +1865,15 @@
"vpnNotFound": "VPN not found",
"extensionNotFound": "Extension not found",
"extensionGroupNotFound": "Extension group not found",
"extensionUnsupportedFileType": "That file type isn't supported. An extension has to be a .crx or .zip archive, or a folder.",
"extensionDirNotFound": "That folder no longer exists.",
"extensionNotADirectory": "That path is not a folder.",
"extensionManifestMissing": "There is no manifest.json in that folder. Choose the folder that holds the extension's manifest.json.",
"extensionManifestInvalid": "The manifest.json in that folder could not be read.",
"extensionDirTooLarge": "That folder is too large to copy into Donut (the limit is 256 MB and 20,000 files). Link it in place instead.",
"extensionPathHasComma": "That folder's path contains a comma, which Chromium cannot load. Rename or move the folder.",
"extensionLinkRequiresDirectory": "Only a folder can be loaded in place. Turn linking off to add an archive.",
"extensionLinkedCannotSync": "This extension is loaded from a folder on this machine, so there is nothing to sync.",
"cannotModifyCloudManagedProxy": "Cannot modify sync for a cloud-managed proxy",
"syncLockedByProfile": "Sync cannot be disabled while this is used by synced profiles",
"syncNotConfigured": "Sync is not configured. Sign in or configure a self-hosted server first.",
@@ -1914,7 +1974,10 @@
"malformed": "The VLESS URI is invalid."
},
"camoufoxRemoved": "Camoufox is no longer supported. Recreate this profile with Wayfern.",
"noE2ePasswordSet": "No end-to-end encryption password is set. Set one before syncing encrypted data."
"noE2ePasswordSet": "No end-to-end encryption password is set. Set one before syncing encrypted data.",
"importSourceNotChromium": "This folder is not a Chromium browser profile",
"importSourceNotChromiumNamed": "{{family}} profiles cannot be imported; only Chromium-based browsers are supported",
"importSourceBrowserRunning": "Close {{browser}} first, or choose to import anyway"
},
"rail": {
"profiles": "Profiles",
@@ -2130,7 +2193,7 @@
},
"wayfernBlocked": {
"title": "Browser automation paused",
"description": "Your account was temporarily restricted from Pro browser features, usually from signing in on multiple devices at once. Sign out of other devices, then relaunch the profile to restore it."
"description": "Browser automation runs on one device at a time, and another signed-in device is currently using it. Sign out there, then relaunch the profile."
},
"wayfernConfigDialog": {
"titleView": "View Fingerprint Settings - {{name}} ({{browser}})",
@@ -2178,7 +2241,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."
},
"empty": {
"title": "No profiles are enrolled",
@@ -2428,7 +2491,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 +2551,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 +2572,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."
}
}
+80 -14
View File
@@ -636,6 +636,7 @@
"connectionSuccess": "¡Conexión exitosa!",
"serverError": "El servidor respondió con un error",
"connectFailed": "Error al conectar con el servidor",
"storageEndpoint": "Almacenamiento: {{endpoint}}",
"settingsSaved": "Ajustes de sincronización guardados",
"saveFailed": "Error al guardar los ajustes",
"disconnected": "Sincronización desconectada",
@@ -1232,6 +1233,12 @@
"syncing": "Sincronizando",
"synced": "Sincronizado",
"error": "Error"
},
"ephemeral": {
"cookiesUnavailable": "Los perfiles efímeros se descartan al cerrar el navegador, así que aquí no hay cookies que gestionar.",
"extensionsUnavailable": "Los perfiles efímeros se descartan al cerrar el navegador, así que no se les pueden asignar grupos de extensiones.",
"syncUnavailable": "Los perfiles efímeros se descartan al cerrar el navegador, así que no hay nada que sincronizar con la nube.",
"hint": "Crea un perfil normal si necesitas que esto se conserve."
}
},
"extensions": {
@@ -1267,7 +1274,7 @@
"deleteConfirmDescription": "¿Estás seguro de que deseas eliminar \"{{name}}\"? Esta acción no se puede deshacer.",
"deleteGroupConfirmTitle": "Eliminar Grupo de Extensiones",
"deleteGroupConfirmDescription": "¿Estás seguro de que deseas eliminar el grupo \"{{name}}\"? Esta acción no se puede deshacer.",
"invalidFileType": "Tipo de archivo no válido. Suba un archivo .crx, .xpi o .zip.",
"invalidFileType": "Tipo de archivo no válido. Elige un archivo .crx o .zip.",
"readError": "No se pudo leer el archivo de extensión.",
"assignTitle": "Asignar Grupo de Extensiones",
"assignDescription": "Asignar {{count}} perfil(es) seleccionado(s) a un grupo de extensiones.",
@@ -1275,7 +1282,6 @@
"assignSuccess": "Grupo de extensiones asignado exitosamente",
"editExtension": "Editar extensión",
"updateSuccess": "Extensión actualizada exitosamente",
"reupload": "Re-subir",
"version": "Versión",
"author": "Autor",
"homepage": "Página de inicio",
@@ -1283,10 +1289,26 @@
"editGroupDescription": "Actualiza el nombre del grupo y gestiona qué extensiones están incluidas.",
"groupExtensions": "Extensiones en este grupo",
"noExtensionsInGroup": "Aún no se han añadido extensiones",
"editExtensionDescription": "Actualizar el nombre de la extensión, ver metadatos o volver a cargar el archivo de extensión.",
"editExtensionDescription": "Actualiza el nombre de la extensión, consulta sus metadatos o reemplázala por otro archivo comprimido o carpeta.",
"metadata": "Metadatos",
"noMetadata": "No hay metadatos disponibles del manifiesto.",
"selectFile": "Elegir archivo",
"loadUnpacked": "Cargar sin empaquetar",
"loadUnpackedTooltip": "Carga una extensión desde una carpeta que contenga manifest.json",
"selectFolderTitle": "Seleccionar carpeta de la extensión",
"selectedFolder": "Carpeta seleccionada",
"selectFolder": "Elegir carpeta",
"linkFolder": "Cargar directamente desde esta carpeta",
"linkFolderOff": "La carpeta se copia en Donut. La extensión es portátil y se sincroniza con tus otros dispositivos.",
"linkFolderOn": "Donut carga la extensión directamente desde esta carpeta en cada inicio. Tus cambios se aplican al abrir el navegador de nuevo, pero la extensión permanece en este equipo y nunca se sincroniza.",
"replaceSource": "Reemplazar origen",
"linkedNoSync": "Las extensiones enlazadas permanecen en este equipo y no se pueden sincronizar.",
"uploadFailed": "No se pudo añadir la extensión",
"updateFailed": "No se pudo actualizar la extensión",
"deleteFailed": "No se pudo eliminar la extensión",
"groupCreateFailed": "No se pudo crear el grupo de extensiones",
"groupUpdateFailed": "No se pudo actualizar el grupo de extensiones",
"groupDeleteFailed": "No se pudo eliminar el grupo de extensiones",
"syncEnabled": "Sincronización habilitada",
"syncDisabled": "Sincronización deshabilitada",
"syncEnableTooltip": "Habilitar sincronización",
@@ -1299,6 +1321,14 @@
"groupsTitle": "Eliminar grupos de extensiones",
"groupsDescription": "¿Eliminar {{count}} grupos de extensiones? {{names}}",
"confirmButton": "Eliminar"
},
"source": {
"label": "Origen",
"archive": "Archivo comprimido",
"unpacked": "Carpeta sin empaquetar",
"linked": "Carpeta enlazada",
"folderLabel": "Carpeta",
"linkedTooltip": "Se carga directamente desde {{path}}"
}
},
"pro": {
@@ -1471,7 +1501,28 @@
"vpnOptional": "VPN (opcional)",
"noVpn": "Sin VPN",
"advancedOptions": "Opciones avanzadas",
"configureFingerprint": "Configurar huella digital (opcional)"
"configureFingerprint": "Configurar huella digital (opcional)",
"reportCookies": "Cookies",
"reportPasswords": "Contraseñas",
"reportAutofill": "Métodos de pago",
"reportExtensions": "Extensiones",
"reportHistory": "Historial",
"reportBookmarks": "Marcadores",
"reportLocalStorage": "Datos de sitios",
"reportNothingCarried": "No se transfirió ningún dato legible",
"reportUnrecoverable": "No se pudo descifrar: {{count}}",
"closeSourceBrowserHint": "Cierra el navegador de origen y vuelve a intentarlo para obtener una copia completa, o importa ahora aceptando que los datos de sitios pueden quedar incompletos.",
"importAnyway": "Importar de todos modos",
"warnings": {
"secretsNotMigrated": "No se pudieron desbloquear las cookies ni las contraseñas, así que tendrás que iniciar sesión de nuevo.",
"appBoundEncrypted": "Chrome 127+ en Windows vincula las cookies al propio navegador; ninguna otra aplicación puede migrarlas.",
"storeTooOld": "Una base de datos era demasiado antigua para este navegador y se omitió.",
"storeTooNew": "Una base de datos procede de un navegador más reciente que este y se omitió.",
"sourceBrowserRunning": "El navegador de origen estaba en ejecución, por lo que los datos de sitios pueden estar incompletos.",
"securePreferencesReset": "Los ajustes protegidos, como la página de inicio y el buscador, volvieron a sus valores predeterminados.",
"extensionsPartial": "Algunas extensiones pertenecían al navegador de origen y no se transfirieron.",
"storeUnreadable": "No se pudo leer una base de datos y se omitió en lugar de copiarla dañada."
}
},
"syncTooltips": {
"syncing": "Sincronizando...",
@@ -1821,6 +1872,15 @@
"vpnNotFound": "VPN no encontrada",
"extensionNotFound": "Extensión no encontrada",
"extensionGroupNotFound": "Grupo de extensiones no encontrado",
"extensionUnsupportedFileType": "Ese tipo de archivo no es compatible. Una extensión debe ser un archivo .crx o .zip, o una carpeta.",
"extensionDirNotFound": "Esa carpeta ya no existe.",
"extensionNotADirectory": "Esa ruta no es una carpeta.",
"extensionManifestMissing": "No hay ningún manifest.json en esa carpeta. Elige la carpeta que contiene el manifest.json de la extensión.",
"extensionManifestInvalid": "No se pudo leer el manifest.json de esa carpeta.",
"extensionDirTooLarge": "Esa carpeta es demasiado grande para copiarla en Donut (el límite es 256 MB y 20 000 archivos). Enlázala en su ubicación.",
"extensionPathHasComma": "La ruta de esa carpeta contiene una coma, que Chromium no puede cargar. Cambia el nombre de la carpeta o muévela.",
"extensionLinkRequiresDirectory": "Solo se puede cargar en su ubicación una carpeta. Desactiva el enlace para añadir un archivo comprimido.",
"extensionLinkedCannotSync": "Esta extensión se carga desde una carpeta de este equipo, así que no hay nada que sincronizar.",
"cannotModifyCloudManagedProxy": "No se puede modificar la sincronización de un proxy gestionado en la nube",
"syncLockedByProfile": "No se puede desactivar la sincronización mientras se usa en perfiles sincronizados",
"syncNotConfigured": "La sincronización no está configurada. Inicia sesión o configura un servidor propio.",
@@ -1921,7 +1981,10 @@
"malformed": "La URI VLESS no es válida."
},
"camoufoxRemoved": "Camoufox ya no es compatible. Vuelve a crear este perfil con Wayfern.",
"noE2ePasswordSet": "No hay contraseña de cifrado de extremo a extremo. Establece una antes de sincronizar datos cifrados."
"noE2ePasswordSet": "No hay contraseña de cifrado de extremo a extremo. Establece una antes de sincronizar datos cifrados.",
"importSourceNotChromium": "Esta carpeta no es un perfil de navegador Chromium",
"importSourceNotChromiumNamed": "Los perfiles de {{family}} no se pueden importar; solo se admiten navegadores basados en Chromium",
"importSourceBrowserRunning": "Cierra {{browser}} primero o elige importar de todos modos"
},
"rail": {
"profiles": "Perfiles",
@@ -2137,7 +2200,7 @@
},
"wayfernBlocked": {
"title": "Automatización del navegador en pausa",
"description": "Tu cuenta fue restringida temporalmente de las funciones Pro del navegador, normalmente por iniciar sesión en varios dispositivos a la vez. Cierra sesión en los demás dispositivos y vuelve a iniciar el perfil para restaurarla."
"description": "La automatización del navegador funciona en un solo dispositivo a la vez y otro dispositivo con la sesión abierta la está usando. Cierra la sesión allí y vuelve a iniciar el perfil."
},
"wayfernConfigDialog": {
"titleView": "Ver configuración de huella - {{name}} ({{browser}})",
@@ -2185,7 +2248,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."
},
"empty": {
"title": "No hay perfiles inscritos",
@@ -2456,7 +2519,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 +2580,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 +2601,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."
}
}
+80 -14
View File
@@ -636,6 +636,7 @@
"connectionSuccess": "Connexion réussie !",
"serverError": "Le serveur a répondu avec une erreur",
"connectFailed": "Échec de la connexion au serveur",
"storageEndpoint": "Stockage : {{endpoint}}",
"settingsSaved": "Paramètres de synchronisation enregistrés",
"saveFailed": "Échec de lenregistrement des paramètres",
"disconnected": "Synchronisation déconnectée",
@@ -1232,6 +1233,12 @@
"syncing": "Synchronisation",
"synced": "Synchronisé",
"error": "Erreur"
},
"ephemeral": {
"cookiesUnavailable": "Les profils éphémères sont supprimés à la fermeture du navigateur : il n'y a donc aucun cookie à gérer ici.",
"extensionsUnavailable": "Les profils éphémères sont supprimés à la fermeture du navigateur : aucun groupe d'extensions ne peut leur être attribué.",
"syncUnavailable": "Les profils éphémères sont supprimés à la fermeture du navigateur : il n'y a rien à synchroniser vers le cloud.",
"hint": "Créez un profil normal si vous avez besoin de conserver ces données."
}
},
"extensions": {
@@ -1267,7 +1274,7 @@
"deleteConfirmDescription": "Êtes-vous sûr de vouloir supprimer \"{{name}}\" ? Cette action est irréversible.",
"deleteGroupConfirmTitle": "Supprimer le Groupe d'Extensions",
"deleteGroupConfirmDescription": "Êtes-vous sûr de vouloir supprimer le groupe \"{{name}}\" ? Cette action est irréversible.",
"invalidFileType": "Type de fichier non valide. Veuillez télécharger un fichier .crx, .xpi ou .zip.",
"invalidFileType": "Type de fichier non valide. Choisissez un fichier .crx ou .zip.",
"readError": "Impossible de lire le fichier d'extension.",
"assignTitle": "Assigner un Groupe d'Extensions",
"assignDescription": "Assigner {{count}} profil(s) sélectionné(s) à un groupe d'extensions.",
@@ -1275,7 +1282,6 @@
"assignSuccess": "Groupe d'extensions assigné avec succès",
"editExtension": "Modifier l'extension",
"updateSuccess": "Extension mise à jour avec succès",
"reupload": "Re-télécharger",
"version": "Version",
"author": "Auteur",
"homepage": "Page d'accueil",
@@ -1283,10 +1289,26 @@
"editGroupDescription": "Mettez à jour le nom du groupe et gérez les extensions incluses.",
"groupExtensions": "Extensions dans ce groupe",
"noExtensionsInGroup": "Aucune extension ajoutée",
"editExtensionDescription": "Modifier le nom de l'extension, voir les métadonnées ou re-télécharger le fichier d'extension.",
"editExtensionDescription": "Modifiez le nom de l'extension, consultez ses métadonnées ou remplacez-la par une autre archive ou un autre dossier.",
"metadata": "Métadonnées",
"noMetadata": "Aucune métadonnée disponible depuis le manifeste.",
"selectFile": "Choisir un fichier",
"loadUnpacked": "Charger non empaquetée",
"loadUnpackedTooltip": "Chargez une extension depuis un dossier contenant manifest.json",
"selectFolderTitle": "Sélectionner le dossier de l'extension",
"selectedFolder": "Dossier sélectionné",
"selectFolder": "Choisir un dossier",
"linkFolder": "Charger directement depuis ce dossier",
"linkFolderOff": "Le dossier est copié dans Donut. L'extension est portable et se synchronise avec vos autres appareils.",
"linkFolderOn": "Donut charge l'extension directement depuis ce dossier à chaque lancement. Vos modifications s'appliquent au prochain démarrage du navigateur, mais l'extension reste sur cet ordinateur et ne se synchronise jamais.",
"replaceSource": "Remplacer la source",
"linkedNoSync": "Les extensions liées restent sur cet ordinateur et ne peuvent pas être synchronisées.",
"uploadFailed": "Échec de l'ajout de l'extension",
"updateFailed": "Échec de la mise à jour de l'extension",
"deleteFailed": "Échec de la suppression de l'extension",
"groupCreateFailed": "Échec de la création du groupe d'extensions",
"groupUpdateFailed": "Échec de la mise à jour du groupe d'extensions",
"groupDeleteFailed": "Échec de la suppression du groupe d'extensions",
"syncEnabled": "Synchronisation activée",
"syncDisabled": "Synchronisation désactivée",
"syncEnableTooltip": "Activer la synchronisation",
@@ -1299,6 +1321,14 @@
"groupsTitle": "Supprimer les groupes d'extensions",
"groupsDescription": "Supprimer {{count}} groupes d'extensions ? {{names}}",
"confirmButton": "Supprimer"
},
"source": {
"label": "Source",
"archive": "Archive",
"unpacked": "Dossier non empaqueté",
"linked": "Dossier lié",
"folderLabel": "Dossier",
"linkedTooltip": "Chargée directement depuis {{path}}"
}
},
"pro": {
@@ -1471,7 +1501,28 @@
"vpnOptional": "VPN (facultatif)",
"noVpn": "Sans VPN",
"advancedOptions": "Options avancées",
"configureFingerprint": "Configurer l'empreinte (facultatif)"
"configureFingerprint": "Configurer l'empreinte (facultatif)",
"reportCookies": "Cookies",
"reportPasswords": "Mots de passe",
"reportAutofill": "Moyens de paiement",
"reportExtensions": "Extensions",
"reportHistory": "Historique",
"reportBookmarks": "Favoris",
"reportLocalStorage": "Données de sites",
"reportNothingCarried": "Aucune donnée lisible n'a été transférée",
"reportUnrecoverable": "Déchiffrement impossible : {{count}}",
"closeSourceBrowserHint": "Fermez le navigateur source et réessayez pour obtenir une copie complète, ou importez maintenant en acceptant que les données de sites soient incomplètes.",
"importAnyway": "Importer quand même",
"warnings": {
"secretsNotMigrated": "Les cookies et les mots de passe n'ont pas pu être déverrouillés : vous devrez vous reconnecter.",
"appBoundEncrypted": "Chrome 127+ sous Windows lie les cookies au navigateur lui-même ; aucune autre application ne peut les migrer.",
"storeTooOld": "Une base de données était trop ancienne pour ce navigateur et a été ignorée.",
"storeTooNew": "Une base de données provient d'un navigateur plus récent que celui-ci et a été ignorée.",
"sourceBrowserRunning": "Le navigateur source était en cours d'exécution, les données de sites peuvent donc être incomplètes.",
"securePreferencesReset": "Les réglages protégés, comme la page d'accueil et le moteur de recherche, sont revenus aux valeurs par défaut.",
"extensionsPartial": "Certaines extensions appartenaient au navigateur source et n'ont pas été transférées.",
"storeUnreadable": "Une base de données n'a pas pu être lue et a été ignorée plutôt que copiée endommagée."
}
},
"syncTooltips": {
"syncing": "Synchronisation...",
@@ -1821,6 +1872,15 @@
"vpnNotFound": "VPN introuvable",
"extensionNotFound": "Extension introuvable",
"extensionGroupNotFound": "Groupe d'extensions introuvable",
"extensionUnsupportedFileType": "Ce type de fichier n'est pas pris en charge. Une extension doit être une archive .crx ou .zip, ou un dossier.",
"extensionDirNotFound": "Ce dossier n'existe plus.",
"extensionNotADirectory": "Ce chemin n'est pas un dossier.",
"extensionManifestMissing": "Il n'y a pas de manifest.json dans ce dossier. Choisissez le dossier qui contient le manifest.json de l'extension.",
"extensionManifestInvalid": "Le fichier manifest.json de ce dossier n'a pas pu être lu.",
"extensionDirTooLarge": "Ce dossier est trop volumineux pour être copié dans Donut (la limite est de 256 Mo et 20 000 fichiers). Liez-le sur place à la place.",
"extensionPathHasComma": "Le chemin de ce dossier contient une virgule, que Chromium ne peut pas charger. Renommez ou déplacez le dossier.",
"extensionLinkRequiresDirectory": "Seul un dossier peut être chargé sur place. Désactivez la liaison pour ajouter une archive.",
"extensionLinkedCannotSync": "Cette extension est chargée depuis un dossier de cet ordinateur : il n'y a rien à synchroniser.",
"cannotModifyCloudManagedProxy": "Impossible de modifier la synchronisation d'un proxy géré dans le cloud",
"syncLockedByProfile": "La synchronisation ne peut pas être désactivée tant qu'elle est utilisée par des profils synchronisés",
"syncNotConfigured": "La synchronisation n'est pas configurée. Connectez-vous ou configurez un serveur auto-hébergé.",
@@ -1921,7 +1981,10 @@
"malformed": "L'URI VLESS n'est pas valide."
},
"camoufoxRemoved": "Camoufox n'est plus pris en charge. Recréez ce profil avec Wayfern.",
"noE2ePasswordSet": "Aucun mot de passe de chiffrement de bout en bout n'est défini. Définissez-en un avant de synchroniser des données chiffrées."
"noE2ePasswordSet": "Aucun mot de passe de chiffrement de bout en bout n'est défini. Définissez-en un avant de synchroniser des données chiffrées.",
"importSourceNotChromium": "Ce dossier n'est pas un profil de navigateur Chromium",
"importSourceNotChromiumNamed": "Les profils {{family}} ne peuvent pas être importés ; seuls les navigateurs basés sur Chromium sont pris en charge",
"importSourceBrowserRunning": "Fermez d'abord {{browser}}, ou choisissez d'importer quand même"
},
"rail": {
"profiles": "Profils",
@@ -2137,7 +2200,7 @@
},
"wayfernBlocked": {
"title": "Automatisation du navigateur en pause",
"description": "Votre compte a été temporairement privé des fonctionnalités Pro du navigateur, généralement à cause d'une connexion sur plusieurs appareils à la fois. Déconnectez-vous des autres appareils, puis relancez le profil pour la rétablir."
"description": "L'automatisation du navigateur fonctionne sur un seul appareil à la fois, et un autre appareil connecté l'utilise actuellement. Déconnectez-vous sur cet appareil, puis relancez le profil."
},
"wayfernConfigDialog": {
"titleView": "Voir les paramètres d'empreinte - {{name}} ({{browser}})",
@@ -2185,7 +2248,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é."
},
"empty": {
"title": "Aucun profil inscrit",
@@ -2456,7 +2519,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 +2580,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 +2601,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."
}
}
+80 -14
View File
@@ -635,6 +635,7 @@
"connectionSuccess": "接続に成功しました!",
"serverError": "サーバーがエラーで応答しました",
"connectFailed": "サーバーへの接続に失敗しました",
"storageEndpoint": "ストレージ: {{endpoint}}",
"settingsSaved": "同期設定を保存しました",
"saveFailed": "設定の保存に失敗しました",
"disconnected": "同期を切断しました",
@@ -1229,6 +1230,12 @@
"syncing": "同期中",
"synced": "同期済み",
"error": "エラー"
},
"ephemeral": {
"cookiesUnavailable": "一時プロファイルはブラウザーを閉じると破棄されるため、ここで管理できる Cookie はありません。",
"extensionsUnavailable": "一時プロファイルはブラウザーを閉じると破棄されるため、拡張機能グループを割り当てられません。",
"syncUnavailable": "一時プロファイルはブラウザーを閉じると破棄されるため、クラウドに同期するものはありません。",
"hint": "データを保持したい場合は通常のプロファイルを作成してください。"
}
},
"extensions": {
@@ -1264,7 +1271,7 @@
"deleteConfirmDescription": "「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
"deleteGroupConfirmTitle": "拡張機能グループを削除",
"deleteGroupConfirmDescription": "グループ「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
"invalidFileType": "無効なファイルタイプです。.crx、.xpi、または .zip ファイルをアップロードしてください。",
"invalidFileType": "ファイル形式が正しくありません。.crx または .zip ファイルを選択してください。",
"readError": "拡張機能ファイルの読み取りに失敗しました。",
"assignTitle": "拡張機能グループの割り当て",
"assignDescription": "選択した{{count}}件のプロファイルを拡張機能グループに割り当てます。",
@@ -1272,7 +1279,6 @@
"assignSuccess": "拡張機能グループが正常に割り当てられました",
"editExtension": "拡張機能を編集",
"updateSuccess": "拡張機能が正常に更新されました",
"reupload": "再アップロード",
"version": "バージョン",
"author": "作者",
"homepage": "ホームページ",
@@ -1280,10 +1286,26 @@
"editGroupDescription": "グループ名を更新し、含まれる拡張機能を管理します。",
"groupExtensions": "このグループの拡張機能",
"noExtensionsInGroup": "拡張機能がまだ追加されていません",
"editExtensionDescription": "拡張機能の名前を更新、メタデータを表示、またはファイルを再アップロードします。",
"editExtensionDescription": "拡張機能の名前を変更したり、メタデータを確認したり、別のアーカイブやフォルダに置き換えたりできます。",
"metadata": "メタデータ",
"noMetadata": "マニフェストからのメタデータはありません。",
"selectFile": "ファイルを選択",
"loadUnpacked": "フォルダから読み込む",
"loadUnpackedTooltip": "manifest.json を含むフォルダから拡張機能を読み込みます",
"selectFolderTitle": "拡張機能のフォルダを選択",
"selectedFolder": "選択したフォルダ",
"selectFolder": "フォルダを選択",
"linkFolder": "このフォルダから直接読み込む",
"linkFolderOff": "フォルダは Donut にコピーされます。拡張機能は持ち運べるようになり、他のデバイスにも同期されます。",
"linkFolderOn": "Donut は起動のたびにこのフォルダから直接拡張機能を読み込みます。編集内容は次回のブラウザ起動時に反映されますが、拡張機能はこの端末にのみ残り、同期されません。",
"replaceSource": "ソースを置き換える",
"linkedNoSync": "リンクした拡張機能はこの端末にのみ残るため、同期できません。",
"uploadFailed": "拡張機能を追加できませんでした",
"updateFailed": "拡張機能を更新できませんでした",
"deleteFailed": "拡張機能を削除できませんでした",
"groupCreateFailed": "拡張機能グループを作成できませんでした",
"groupUpdateFailed": "拡張機能グループを更新できませんでした",
"groupDeleteFailed": "拡張機能グループを削除できませんでした",
"syncEnabled": "同期が有効",
"syncDisabled": "同期が無効",
"syncEnableTooltip": "同期を有効にする",
@@ -1296,6 +1318,14 @@
"groupsTitle": "拡張機能グループを削除",
"groupsDescription": "{{count}}件の拡張機能グループを削除しますか? {{names}}",
"confirmButton": "削除"
},
"source": {
"label": "ソース",
"archive": "アーカイブ",
"unpacked": "展開済みフォルダ",
"linked": "リンクされたフォルダ",
"folderLabel": "フォルダ",
"linkedTooltip": "{{path}} から直接読み込みます"
}
},
"pro": {
@@ -1467,7 +1497,28 @@
"vpnOptional": "VPN(任意)",
"noVpn": "VPNなし",
"advancedOptions": "詳細オプション",
"configureFingerprint": "フィンガープリントを設定(任意)"
"configureFingerprint": "フィンガープリントを設定(任意)",
"reportCookies": "Cookie",
"reportPasswords": "パスワード",
"reportAutofill": "お支払い方法",
"reportExtensions": "拡張機能",
"reportHistory": "履歴",
"reportBookmarks": "ブックマーク",
"reportLocalStorage": "サイトデータ",
"reportNothingCarried": "読み取り可能なデータは引き継がれませんでした",
"reportUnrecoverable": "復号できませんでした: {{count}}",
"closeSourceBrowserHint": "完全にコピーするには、元のブラウザーを閉じてからもう一度お試しください。サイトデータが不完全になることを承知のうえで、このままインポートすることもできます。",
"importAnyway": "このままインポート",
"warnings": {
"secretsNotMigrated": "Cookie とパスワードのロックを解除できなかったため、再度サインインが必要です。",
"appBoundEncrypted": "Windows の Chrome 127 以降は Cookie をブラウザー自体に紐付けるため、他のアプリからは移行できません。",
"storeTooOld": "このブラウザーでは開けない古いデータベースがあったため、スキップしました。",
"storeTooNew": "このブラウザーより新しいブラウザーのデータベースだったため、スキップしました。",
"sourceBrowserRunning": "元のブラウザーが実行中だったため、サイトデータが不完全な可能性があります。",
"securePreferencesReset": "ホームページや検索エンジンなど、保護された設定は既定値に戻りました。",
"extensionsPartial": "一部の拡張機能は元のブラウザー付属のもので、引き継がれませんでした。",
"storeUnreadable": "読み取れないデータベースがあったため、破損したままコピーせずスキップしました。"
}
},
"syncTooltips": {
"syncing": "同期中...",
@@ -1814,6 +1865,15 @@
"vpnNotFound": "VPNが見つかりません",
"extensionNotFound": "拡張機能が見つかりません",
"extensionGroupNotFound": "拡張機能グループが見つかりません",
"extensionUnsupportedFileType": "この形式には対応していません。拡張機能は .crx または .zip アーカイブ、あるいはフォルダである必要があります。",
"extensionDirNotFound": "そのフォルダは存在しません。",
"extensionNotADirectory": "そのパスはフォルダではありません。",
"extensionManifestMissing": "そのフォルダに manifest.json がありません。拡張機能の manifest.json があるフォルダを選択してください。",
"extensionManifestInvalid": "そのフォルダの manifest.json を読み取れませんでした。",
"extensionDirTooLarge": "そのフォルダは大きすぎて Donut にコピーできません(上限は 256 MB・20,000 ファイル)。代わりにリンクして読み込んでください。",
"extensionPathHasComma": "そのフォルダのパスにカンマが含まれており、Chromium が読み込めません。フォルダの名前を変更するか、移動してください。",
"extensionLinkRequiresDirectory": "その場で読み込めるのはフォルダのみです。アーカイブを追加するにはリンクをオフにしてください。",
"extensionLinkedCannotSync": "この拡張機能はこの端末のフォルダから読み込まれているため、同期する対象がありません。",
"cannotModifyCloudManagedProxy": "クラウド管理のプロキシの同期は変更できません",
"syncLockedByProfile": "同期済みプロファイルで使用中のため、同期を無効にできません",
"syncNotConfigured": "同期が設定されていません。サインインするか、セルフホストサーバーを設定してください。",
@@ -1914,7 +1974,10 @@
"malformed": "VLESS URIが無効です。"
},
"camoufoxRemoved": "Camoufoxはサポートされなくなりました。Wayfernでこのプロファイルを作り直してください。",
"noE2ePasswordSet": "エンドツーエンド暗号化のパスワードが設定されていません。暗号化データを同期する前に設定してください。"
"noE2ePasswordSet": "エンドツーエンド暗号化のパスワードが設定されていません。暗号化データを同期する前に設定してください。",
"importSourceNotChromium": "このフォルダーは Chromium ブラウザーのプロファイルではありません",
"importSourceNotChromiumNamed": "{{family}} のプロファイルはインポートできません。Chromium 系ブラウザーのみ対応しています",
"importSourceBrowserRunning": "先に {{browser}} を閉じるか、このままインポートを選択してください"
},
"rail": {
"profiles": "プロファイル",
@@ -2130,7 +2193,7 @@
},
"wayfernBlocked": {
"title": "ブラウザの自動化が一時停止しました",
"description": "通常は複数のデバイスで同時にサインインしたことが原因で、アカウントのProブラウザ機能が一時的に制限されました。他のデバイスからサインアウトし、プロファイルを再起動すると復元されます。"
"description": "ブラウザの自動化は同時に1台のデバイスでのみ利用できます。現在、サインイン中の別のデバイスが使用しています。そのデバイスからサインアウトし、プロファイルを再起動してください。"
},
"wayfernConfigDialog": {
"titleView": "フィンガープリント設定を表示 - {{name}} ({{browser}})",
@@ -2178,7 +2241,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot はリモートマシンで夜間にプロファイルをウォームアップするため、お使いのコンピューターを起動していなくても Cookie と履歴が維持されます。Pro または Team プランが必要です。"
"hint": "Cookie Bot はリモートマシンで夜間にプロファイルをウォームアップするため、お使いのコンピューターを起動していなくても Cookie と履歴が維持されます。"
},
"empty": {
"title": "登録されたプロファイルはありません",
@@ -2428,7 +2491,7 @@
},
"actionBar": {
"enrol": "Cookie Bot に登録",
"proRequired": "Cookie Bot には Pro または Team プランが必要です",
"proRequired": "Cookie Bot には有料プランが必要です",
"noneEligible": "選択したプロファイルはいずれもリモートでウォームアップできません"
},
"actions": {
@@ -2488,15 +2551,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 +2572,8 @@
"dontWarnExtensions": "これらの拡張機能について今後警告しない",
"applyToRemaining": "この選択を残りのプロファイルにも適用",
"cancelledSummary": "{{total}}件中{{cancelled}}件の起動をキャンセルしました",
"cancelled": "起動をキャンセルしました",
"vpnExtensionEntry": " {{version}}{{capability}}、{{source}}",
"vpnExtensionEntryNoVersion": "{{capability}}、{{source}}",
"scanIncompleteMissing": "このプロファイルはまだ起動されていないため、Donutが管理する拡張機能のみ確認できました。"
}
}
+80 -14
View File
@@ -635,6 +635,7 @@
"connectionSuccess": "연결 성공!",
"serverError": "서버가 오류로 응답했습니다",
"connectFailed": "서버에 연결하지 못했습니다",
"storageEndpoint": "스토리지: {{endpoint}}",
"settingsSaved": "동기화 설정이 저장되었습니다",
"saveFailed": "설정 저장 실패",
"disconnected": "동기화 연결 끊김",
@@ -1229,6 +1230,12 @@
"syncing": "동기화 중",
"synced": "동기화됨",
"error": "오류"
},
"ephemeral": {
"cookiesUnavailable": "임시 프로필은 브라우저를 닫으면 삭제되므로 여기에서 관리할 쿠키가 없습니다.",
"extensionsUnavailable": "임시 프로필은 브라우저를 닫으면 삭제되므로 확장 프로그램 그룹을 지정할 수 없습니다.",
"syncUnavailable": "임시 프로필은 브라우저를 닫으면 삭제되므로 클라우드에 동기화할 항목이 없습니다.",
"hint": "데이터를 유지하려면 일반 프로필을 만드세요."
}
},
"extensions": {
@@ -1264,7 +1271,7 @@
"deleteConfirmDescription": "\"{{name}}\"을(를) 정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"deleteGroupConfirmTitle": "확장 프로그램 그룹 삭제",
"deleteGroupConfirmDescription": "그룹 \"{{name}}\"을(를) 정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"invalidFileType": "잘못된 파일 형입니다. .crx, .xpi 또는 .zip 파일을 업로드하세요.",
"invalidFileType": "지원하지 않는 파일 형입니다. .crx 또는 .zip 파일을 선택하세요.",
"readError": "확장 프로그램 파일 읽기 실패.",
"assignTitle": "확장 프로그램 그룹 할당",
"assignDescription": "선택한 {{count}}개 프로필을 확장 프로그램 그룹에 할당합니다.",
@@ -1272,7 +1279,6 @@
"assignSuccess": "확장 프로그램 그룹이 할당되었습니다",
"editExtension": "확장 프로그램 편집",
"updateSuccess": "확장 프로그램이 업데이트되었습니다",
"reupload": "다시 업로드",
"version": "버전",
"author": "작성자",
"homepage": "홈페이지",
@@ -1280,10 +1286,26 @@
"editGroupDescription": "그룹 이름을 업데이트하고 포함된 확장 프로그램을 관리합니다.",
"groupExtensions": "이 그룹의 확장 프로그램",
"noExtensionsInGroup": "아직 추가된 확장 프로그램이 없습니다",
"editExtensionDescription": "확장 프로그램 이름을 업데이트하거나, 메타데이터를 보거나, 확장 프로그램 파일을 다시 업로드합니다.",
"editExtensionDescription": "확장 프로그램 이름을 변경하고, 메타데이터를 확인하고, 다른 압축 파일이나 폴더로 교체할 수 있습니다.",
"metadata": "메타데이터",
"noMetadata": "manifest에서 사용할 수 있는 메타데이터가 없습니다.",
"selectFile": "파일 선택",
"loadUnpacked": "폴더에서 불러오기",
"loadUnpackedTooltip": "manifest.json이 있는 폴더에서 확장 프로그램을 불러옵니다",
"selectFolderTitle": "확장 프로그램 폴더 선택",
"selectedFolder": "선택한 폴더",
"selectFolder": "폴더 선택",
"linkFolder": "이 폴더에서 바로 불러오기",
"linkFolderOff": "폴더가 Donut으로 복사됩니다. 확장 프로그램을 옮길 수 있고 다른 기기와 동기화됩니다.",
"linkFolderOn": "Donut이 실행할 때마다 이 폴더에서 바로 확장 프로그램을 불러옵니다. 수정한 내용은 브라우저를 다시 시작할 때 적용되지만, 확장 프로그램은 이 컴퓨터에만 남고 동기화되지 않습니다.",
"replaceSource": "소스 교체",
"linkedNoSync": "연결된 확장 프로그램은 이 컴퓨터에만 있어 동기화할 수 없습니다.",
"uploadFailed": "확장 프로그램을 추가하지 못했습니다",
"updateFailed": "확장 프로그램을 업데이트하지 못했습니다",
"deleteFailed": "확장 프로그램을 삭제하지 못했습니다",
"groupCreateFailed": "확장 프로그램 그룹을 만들지 못했습니다",
"groupUpdateFailed": "확장 프로그램 그룹을 업데이트하지 못했습니다",
"groupDeleteFailed": "확장 프로그램 그룹을 삭제하지 못했습니다",
"syncEnabled": "동기화 사용됨",
"syncDisabled": "동기화 사용 안 함",
"syncEnableTooltip": "동기화 사용",
@@ -1296,6 +1318,14 @@
"groupsTitle": "확장 프로그램 그룹 삭제",
"groupsDescription": "{{count}}개의 확장 프로그램 그룹을 삭제하시겠습니까? {{names}}",
"confirmButton": "삭제"
},
"source": {
"label": "소스",
"archive": "압축 파일",
"unpacked": "압축 해제된 폴더",
"linked": "연결된 폴더",
"folderLabel": "폴더",
"linkedTooltip": "{{path}}에서 바로 불러옵니다"
}
},
"pro": {
@@ -1467,7 +1497,28 @@
"vpnOptional": "VPN (선택 사항)",
"noVpn": "VPN 없음",
"advancedOptions": "고급 옵션",
"configureFingerprint": "핑거프린트 구성 (선택 사항)"
"configureFingerprint": "핑거프린트 구성 (선택 사항)",
"reportCookies": "쿠키",
"reportPasswords": "비밀번호",
"reportAutofill": "결제 수단",
"reportExtensions": "확장 프로그램",
"reportHistory": "방문 기록",
"reportBookmarks": "북마크",
"reportLocalStorage": "사이트 데이터",
"reportNothingCarried": "읽을 수 있는 데이터가 이전되지 않았습니다",
"reportUnrecoverable": "복호화할 수 없음: {{count}}",
"closeSourceBrowserHint": "완전하게 복사하려면 원본 브라우저를 닫고 다시 시도하세요. 사이트 데이터가 불완전할 수 있음을 감수하고 지금 가져올 수도 있습니다.",
"importAnyway": "그래도 가져오기",
"warnings": {
"secretsNotMigrated": "쿠키와 비밀번호를 잠금 해제하지 못해 다시 로그인해야 합니다.",
"appBoundEncrypted": "Windows의 Chrome 127 이상은 쿠키를 브라우저 자체에 묶어 두므로 다른 앱에서는 이전할 수 없습니다.",
"storeTooOld": "이 브라우저가 열 수 없을 만큼 오래된 데이터베이스가 있어 건너뛰었습니다.",
"storeTooNew": "이 브라우저보다 최신 브라우저의 데이터베이스여서 건너뛰었습니다.",
"sourceBrowserRunning": "원본 브라우저가 실행 중이어서 사이트 데이터가 불완전할 수 있습니다.",
"securePreferencesReset": "홈페이지와 검색 엔진 같은 보호된 설정이 기본값으로 초기화되었습니다.",
"extensionsPartial": "일부 확장 프로그램은 원본 브라우저의 것이어서 이전되지 않았습니다.",
"storeUnreadable": "읽을 수 없는 데이터베이스가 있어 손상된 채로 복사하지 않고 건너뛰었습니다."
}
},
"syncTooltips": {
"syncing": "동기화 중...",
@@ -1814,6 +1865,15 @@
"vpnNotFound": "VPN을 찾을 수 없습니다",
"extensionNotFound": "확장 프로그램을 찾을 수 없습니다",
"extensionGroupNotFound": "확장 프로그램 그룹을 찾을 수 없습니다",
"extensionUnsupportedFileType": "지원하지 않는 파일 형식입니다. 확장 프로그램은 .crx 또는 .zip 압축 파일이거나 폴더여야 합니다.",
"extensionDirNotFound": "해당 폴더가 더 이상 존재하지 않습니다.",
"extensionNotADirectory": "해당 경로는 폴더가 아닙니다.",
"extensionManifestMissing": "해당 폴더에 manifest.json이 없습니다. 확장 프로그램의 manifest.json이 있는 폴더를 선택하세요.",
"extensionManifestInvalid": "해당 폴더의 manifest.json을 읽을 수 없습니다.",
"extensionDirTooLarge": "폴더가 너무 커서 Donut으로 복사할 수 없습니다(최대 256MB, 20,000개 파일). 대신 폴더를 연결해 사용하세요.",
"extensionPathHasComma": "폴더 경로에 쉼표가 있어 Chromium이 불러올 수 없습니다. 폴더 이름을 바꾸거나 옮기세요.",
"extensionLinkRequiresDirectory": "폴더만 그 자리에서 불러올 수 있습니다. 압축 파일을 추가하려면 연결을 끄세요.",
"extensionLinkedCannotSync": "이 확장 프로그램은 이 컴퓨터의 폴더에서 불러오므로 동기화할 항목이 없습니다.",
"cannotModifyCloudManagedProxy": "클라우드 관리 프록시의 동기화는 수정할 수 없습니다",
"syncLockedByProfile": "동기화된 프로필에서 사용 중인 동안에는 동기화를 비활성화할 수 없습니다",
"syncNotConfigured": "동기화가 구성되지 않았습니다. 먼저 로그인하거나 자체 호스팅 서버를 구성하세요.",
@@ -1914,7 +1974,10 @@
"malformed": "VLESS URI가 올바르지 않습니다."
},
"camoufoxRemoved": "Camoufox는 더 이상 지원되지 않습니다. Wayfern으로 이 프로필을 다시 만드세요.",
"noE2ePasswordSet": "종단 간 암호화 비밀번호가 설정되지 않았습니다. 암호화된 데이터를 동기화하기 전에 설정하세요."
"noE2ePasswordSet": "종단 간 암호화 비밀번호가 설정되지 않았습니다. 암호화된 데이터를 동기화하기 전에 설정하세요.",
"importSourceNotChromium": "이 폴더는 Chromium 브라우저 프로필이 아닙니다",
"importSourceNotChromiumNamed": "{{family}} 프로필은 가져올 수 없습니다. Chromium 기반 브라우저만 지원합니다",
"importSourceBrowserRunning": "{{browser}}을(를) 먼저 닫거나 그래도 가져오기를 선택하세요"
},
"rail": {
"profiles": "프로필",
@@ -2130,7 +2193,7 @@
},
"wayfernBlocked": {
"title": "브라우저 자동화가 일시 중지됨",
"description": "보통 여러 기기에서 동시에 로그인하여 계정의 Pro 브라우저 기능이 일시적으로 제한되었습니다. 다른 기기에서 로그아웃한 후 프로필을 다시 실행하면 복원됩니다."
"description": "브라우저 자동화는 한 번에 기기 한 대에서만 실행되며, 현재 로그인된 다른 기기가 사용 중입니다. 해당 기기에서 로그아웃한 후 프로필을 다시 실행하세요."
},
"wayfernConfigDialog": {
"titleView": "핑거프린트 설정 보기 - {{name}} ({{browser}})",
@@ -2178,7 +2241,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot은 원격 머신에서 밤새 프로필을 예열해, 내 컴퓨터를 켜 두지 않아도 쿠키와 방문 기록이 유지됩니다. Pro 또는 Team 요금제가 필요합니다."
"hint": "Cookie Bot은 원격 머신에서 밤새 프로필을 예열해, 내 컴퓨터를 켜 두지 않아도 쿠키와 방문 기록이 유지됩니다."
},
"empty": {
"title": "등록된 프로필이 없습니다",
@@ -2428,7 +2491,7 @@
},
"actionBar": {
"enrol": "Cookie Bot에 등록",
"proRequired": "Cookie Bot에는 Pro 또는 Team 요금제가 필요합니다",
"proRequired": "Cookie Bot에는 유료 요금제가 필요합니다",
"noneEligible": "선택한 프로필 중 원격으로 예열할 수 있는 것이 없습니다"
},
"actions": {
@@ -2488,15 +2551,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 +2572,8 @@
"dontWarnExtensions": "이 확장 프로그램에 대해 다시 경고하지 않기",
"applyToRemaining": "이 선택을 나머지 프로필에 적용",
"cancelledSummary": "{{total}}개 중 {{cancelled}}개의 실행이 취소됨",
"cancelled": "실행이 취소됨",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"vpnExtensionEntryNoVersion": " — {{capability}}, {{source}}",
"scanIncompleteMissing": "이 프로필은 아직 실행된 적이 없어 Donut이 관리하는 확장 프로그램만 확인할 수 있었습니다."
}
}
+80 -14
View File
@@ -636,6 +636,7 @@
"connectionSuccess": "Conexão bem-sucedida!",
"serverError": "O servidor respondeu com um erro",
"connectFailed": "Falha ao conectar ao servidor",
"storageEndpoint": "Armazenamento: {{endpoint}}",
"settingsSaved": "Configurações de sincronização salvas",
"saveFailed": "Falha ao salvar as configurações",
"disconnected": "Sincronização desconectada",
@@ -1232,6 +1233,12 @@
"syncing": "Sincronizando",
"synced": "Sincronizado",
"error": "Erro"
},
"ephemeral": {
"cookiesUnavailable": "Perfis efêmeros são descartados quando o navegador fecha, portanto não há cookies para gerenciar aqui.",
"extensionsUnavailable": "Perfis efêmeros são descartados quando o navegador fecha, portanto não é possível atribuir grupos de extensões a eles.",
"syncUnavailable": "Perfis efêmeros são descartados quando o navegador fecha, portanto não há nada para sincronizar com a nuvem.",
"hint": "Crie um perfil normal se precisar que isso seja mantido."
}
},
"extensions": {
@@ -1267,7 +1274,7 @@
"deleteConfirmDescription": "Tem certeza de que deseja excluir \"{{name}}\"? Esta ação não pode ser desfeita.",
"deleteGroupConfirmTitle": "Excluir Grupo de Extensões",
"deleteGroupConfirmDescription": "Tem certeza de que deseja excluir o grupo \"{{name}}\"? Esta ação não pode ser desfeita.",
"invalidFileType": "Tipo de arquivo inválido. Envie um arquivo .crx, .xpi ou .zip.",
"invalidFileType": "Tipo de arquivo inválido. Escolha um arquivo .crx ou .zip.",
"readError": "Falha ao ler o arquivo de extensão.",
"assignTitle": "Atribuir Grupo de Extensões",
"assignDescription": "Atribuir {{count}} perfil(is) selecionado(s) a um grupo de extensões.",
@@ -1275,7 +1282,6 @@
"assignSuccess": "Grupo de extensões atribuído com sucesso",
"editExtension": "Editar extensão",
"updateSuccess": "Extensão atualizada com sucesso",
"reupload": "Re-enviar",
"version": "Versão",
"author": "Autor",
"homepage": "Página inicial",
@@ -1283,10 +1289,26 @@
"editGroupDescription": "Atualize o nome do grupo e gerencie quais extensões estão incluídas.",
"groupExtensions": "Extensões neste grupo",
"noExtensionsInGroup": "Nenhuma extensão adicionada ainda",
"editExtensionDescription": "Atualizar o nome da extensão, ver metadados ou reenviar o arquivo da extensão.",
"editExtensionDescription": "Atualize o nome da extensão, veja seus metadados ou substitua-a por outro arquivo compactado ou pasta.",
"metadata": "Metadados",
"noMetadata": "Nenhum metadado disponível do manifesto.",
"selectFile": "Escolher arquivo",
"loadUnpacked": "Carregar descompactada",
"loadUnpackedTooltip": "Carregue uma extensão a partir de uma pasta com manifest.json",
"selectFolderTitle": "Selecionar pasta da extensão",
"selectedFolder": "Pasta selecionada",
"selectFolder": "Escolher pasta",
"linkFolder": "Carregar direto desta pasta",
"linkFolderOff": "A pasta é copiada para o Donut. A extensão fica portátil e sincroniza com seus outros dispositivos.",
"linkFolderOn": "O Donut carrega a extensão direto desta pasta a cada inicialização. Suas edições valem na próxima abertura do navegador, mas a extensão fica só neste computador e nunca sincroniza.",
"replaceSource": "Substituir origem",
"linkedNoSync": "Extensões vinculadas ficam só neste computador e não sincronizam.",
"uploadFailed": "Falha ao adicionar a extensão",
"updateFailed": "Falha ao atualizar a extensão",
"deleteFailed": "Falha ao excluir a extensão",
"groupCreateFailed": "Falha ao criar o grupo de extensões",
"groupUpdateFailed": "Falha ao atualizar o grupo de extensões",
"groupDeleteFailed": "Falha ao excluir o grupo de extensões",
"syncEnabled": "Sincronização ativada",
"syncDisabled": "Sincronização desativada",
"syncEnableTooltip": "Ativar sincronização",
@@ -1299,6 +1321,14 @@
"groupsTitle": "Excluir grupos de extensões",
"groupsDescription": "Excluir {{count}} grupos de extensões? {{names}}",
"confirmButton": "Excluir"
},
"source": {
"label": "Origem",
"archive": "Arquivo compactado",
"unpacked": "Pasta descompactada",
"linked": "Pasta vinculada",
"folderLabel": "Pasta",
"linkedTooltip": "Carregada direto de {{path}}"
}
},
"pro": {
@@ -1471,7 +1501,28 @@
"vpnOptional": "VPN (opcional)",
"noVpn": "Sem VPN",
"advancedOptions": "Opções avançadas",
"configureFingerprint": "Configurar impressão digital (opcional)"
"configureFingerprint": "Configurar impressão digital (opcional)",
"reportCookies": "Cookies",
"reportPasswords": "Senhas",
"reportAutofill": "Formas de pagamento",
"reportExtensions": "Extensões",
"reportHistory": "Histórico",
"reportBookmarks": "Favoritos",
"reportLocalStorage": "Dados de sites",
"reportNothingCarried": "Nenhum dado legível foi transferido",
"reportUnrecoverable": "Não foi possível descriptografar: {{count}}",
"closeSourceBrowserHint": "Feche o navegador de origem e tente de novo para obter uma cópia completa, ou importe agora aceitando que os dados de sites podem ficar incompletos.",
"importAnyway": "Importar mesmo assim",
"warnings": {
"secretsNotMigrated": "Não foi possível desbloquear cookies e senhas, então você precisará entrar novamente.",
"appBoundEncrypted": "O Chrome 127+ no Windows vincula os cookies ao próprio navegador; nenhum outro aplicativo consegue migrá-los.",
"storeTooOld": "Um banco de dados era antigo demais para este navegador e foi ignorado.",
"storeTooNew": "Um banco de dados veio de um navegador mais novo que este e foi ignorado.",
"sourceBrowserRunning": "O navegador de origem estava aberto, então os dados de sites podem estar incompletos.",
"securePreferencesReset": "Configurações protegidas, como página inicial e mecanismo de busca, voltaram ao padrão.",
"extensionsPartial": "Algumas extensões pertenciam ao navegador de origem e não foram transferidas.",
"storeUnreadable": "Não foi possível ler um banco de dados, que foi ignorado em vez de copiado danificado."
}
},
"syncTooltips": {
"syncing": "Sincronizando...",
@@ -1821,6 +1872,15 @@
"vpnNotFound": "VPN não encontrada",
"extensionNotFound": "Extensão não encontrada",
"extensionGroupNotFound": "Grupo de extensões não encontrado",
"extensionUnsupportedFileType": "Esse tipo de arquivo não é compatível. Uma extensão precisa ser um arquivo .crx ou .zip, ou uma pasta.",
"extensionDirNotFound": "Essa pasta não existe mais.",
"extensionNotADirectory": "Esse caminho não é uma pasta.",
"extensionManifestMissing": "Não há manifest.json nessa pasta. Escolha a pasta que contém o manifest.json da extensão.",
"extensionManifestInvalid": "Não foi possível ler o manifest.json dessa pasta.",
"extensionDirTooLarge": "Essa pasta é grande demais para copiar para o Donut (o limite é 256 MB e 20.000 arquivos). Vincule-a no lugar.",
"extensionPathHasComma": "O caminho dessa pasta tem uma vírgula, que o Chromium não consegue carregar. Renomeie ou mova a pasta.",
"extensionLinkRequiresDirectory": "Só uma pasta pode ser carregada no lugar. Desative o vínculo para adicionar um arquivo compactado.",
"extensionLinkedCannotSync": "Esta extensão é carregada de uma pasta deste computador, então não há nada para sincronizar.",
"cannotModifyCloudManagedProxy": "Não é possível modificar a sincronização de um proxy gerenciado na nuvem",
"syncLockedByProfile": "A sincronização não pode ser desativada enquanto estiver em uso por perfis sincronizados",
"syncNotConfigured": "A sincronização não está configurada. Faça login ou configure um servidor auto-hospedado.",
@@ -1921,7 +1981,10 @@
"malformed": "A URI VLESS é inválida."
},
"camoufoxRemoved": "O Camoufox não é mais compatível. Recrie este perfil com o Wayfern.",
"noE2ePasswordSet": "Nenhuma senha de criptografia de ponta a ponta foi definida. Defina uma antes de sincronizar dados criptografados."
"noE2ePasswordSet": "Nenhuma senha de criptografia de ponta a ponta foi definida. Defina uma antes de sincronizar dados criptografados.",
"importSourceNotChromium": "Esta pasta não é um perfil de navegador Chromium",
"importSourceNotChromiumNamed": "Perfis do {{family}} não podem ser importados; apenas navegadores baseados em Chromium são compatíveis",
"importSourceBrowserRunning": "Feche o {{browser}} primeiro ou escolha importar mesmo assim"
},
"rail": {
"profiles": "Perfis",
@@ -2137,7 +2200,7 @@
},
"wayfernBlocked": {
"title": "Automação do navegador pausada",
"description": "Sua conta foi temporariamente restringida dos recursos Pro do navegador, geralmente por entrar em vários dispositivos ao mesmo tempo. Saia dos outros dispositivos e reinicie o perfil para restaurá-la."
"description": "A automação do navegador funciona em um dispositivo por vez e outro dispositivo com a sessão ativa está usando-a. Saia da conta nesse dispositivo e inicie o perfil novamente."
},
"wayfernConfigDialog": {
"titleView": "Ver configurações de impressão digital - {{name}} ({{browser}})",
@@ -2185,7 +2248,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."
},
"empty": {
"title": "Nenhum perfil inscrito",
@@ -2456,7 +2519,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 +2580,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 +2601,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."
}
}
+80 -14
View File
@@ -637,6 +637,7 @@
"connectionSuccess": "Подключение успешно!",
"serverError": "Сервер вернул ошибку",
"connectFailed": "Не удалось подключиться к серверу",
"storageEndpoint": "Хранилище: {{endpoint}}",
"settingsSaved": "Настройки синхронизации сохранены",
"saveFailed": "Не удалось сохранить настройки",
"disconnected": "Синхронизация отключена",
@@ -1235,6 +1236,12 @@
"syncing": "Синхронизация",
"synced": "Синхронизировано",
"error": "Ошибка"
},
"ephemeral": {
"cookiesUnavailable": "Временные профили удаляются при закрытии браузера, поэтому здесь нет cookies для управления.",
"extensionsUnavailable": "Временные профили удаляются при закрытии браузера, поэтому назначить им группы расширений нельзя.",
"syncUnavailable": "Временные профили удаляются при закрытии браузера, поэтому синхронизировать с облаком нечего.",
"hint": "Создайте обычный профиль, если эти данные должны сохраняться."
}
},
"extensions": {
@@ -1270,7 +1277,7 @@
"deleteConfirmDescription": "Вы уверены, что хотите удалить «{{name}}»? Это действие нельзя отменить.",
"deleteGroupConfirmTitle": "Удалить группу расширений",
"deleteGroupConfirmDescription": "Вы уверены, что хотите удалить группу «{{name}}»? Это действие нельзя отменить.",
"invalidFileType": "Недопустимый тип файла. Загрузите файл .crx, .xpi или .zip.",
"invalidFileType": "Неподдерживаемый тип файла. Выберите файл .crx или .zip.",
"readError": "Не удалось прочитать файл расширения.",
"assignTitle": "Назначить группу расширений",
"assignDescription": "Назначить {{count}} выбранных профилей в группу расширений.",
@@ -1278,7 +1285,6 @@
"assignSuccess": "Группа расширений успешно назначена",
"editExtension": "Редактировать расширение",
"updateSuccess": "Расширение успешно обновлено",
"reupload": "Загрузить заново",
"version": "Версия",
"author": "Автор",
"homepage": "Домашняя страница",
@@ -1286,10 +1292,26 @@
"editGroupDescription": "Обновите название группы и управляйте включёнными расширениями.",
"groupExtensions": "Расширения в этой группе",
"noExtensionsInGroup": "Расширения ещё не добавлены",
"editExtensionDescription": "Обновите имя расширения, просмотрите метаданные или загрузите файл расширения повторно.",
"editExtensionDescription": "Измените имя расширения, посмотрите его метаданные или замените его другим архивом либо папкой.",
"metadata": "Метаданные",
"noMetadata": "Метаданные из манифеста недоступны.",
"selectFile": "Выбрать файл",
"loadUnpacked": "Загрузить из папки",
"loadUnpackedTooltip": "Загрузить расширение из папки с файлом manifest.json",
"selectFolderTitle": "Выберите папку расширения",
"selectedFolder": "Выбранная папка",
"selectFolder": "Выбрать папку",
"linkFolder": "Загружать прямо из этой папки",
"linkFolderOff": "Папка копируется в Donut. Расширение можно переносить, и оно синхронизируется с другими устройствами.",
"linkFolderOn": "Donut загружает расширение прямо из этой папки при каждом запуске. Изменения применяются при следующем старте браузера, но расширение остаётся только на этом компьютере и не синхронизируется.",
"replaceSource": "Заменить источник",
"linkedNoSync": "Связанные расширения остаются только на этом компьютере и не синхронизируются.",
"uploadFailed": "Не удалось добавить расширение",
"updateFailed": "Не удалось обновить расширение",
"deleteFailed": "Не удалось удалить расширение",
"groupCreateFailed": "Не удалось создать группу расширений",
"groupUpdateFailed": "Не удалось обновить группу расширений",
"groupDeleteFailed": "Не удалось удалить группу расширений",
"syncEnabled": "Синхронизация включена",
"syncDisabled": "Синхронизация отключена",
"syncEnableTooltip": "Включить синхронизацию",
@@ -1302,6 +1324,14 @@
"groupsTitle": "Удалить группы расширений",
"groupsDescription": "Удалить {{count}} групп расширений? {{names}}",
"confirmButton": "Удалить"
},
"source": {
"label": "Источник",
"archive": "Архив",
"unpacked": "Распакованная папка",
"linked": "Связанная папка",
"folderLabel": "Папка",
"linkedTooltip": "Загружается прямо из {{path}}"
}
},
"pro": {
@@ -1475,7 +1505,28 @@
"vpnOptional": "VPN (необязательно)",
"noVpn": "Без VPN",
"advancedOptions": "Дополнительные параметры",
"configureFingerprint": "Настроить отпечаток (необязательно)"
"configureFingerprint": "Настроить отпечаток (необязательно)",
"reportCookies": "Файлы cookie",
"reportPasswords": "Пароли",
"reportAutofill": "Способы оплаты",
"reportExtensions": "Расширения",
"reportHistory": "История",
"reportBookmarks": "Закладки",
"reportLocalStorage": "Данные сайтов",
"reportNothingCarried": "Читаемые данные не перенесены",
"reportUnrecoverable": "Не удалось расшифровать: {{count}}",
"closeSourceBrowserHint": "Закройте исходный браузер и повторите попытку, чтобы получить полную копию, либо импортируйте сейчас, приняв, что данные сайтов могут оказаться неполными.",
"importAnyway": "Всё равно импортировать",
"warnings": {
"secretsNotMigrated": "Не удалось разблокировать файлы cookie и пароли, поэтому потребуется войти заново.",
"appBoundEncrypted": "Chrome 127+ в Windows привязывает файлы cookie к самому браузеру, и другое приложение не может их перенести.",
"storeTooOld": "База данных оказалась слишком старой для этого браузера и была пропущена.",
"storeTooNew": "База данных создана более новым браузером и была пропущена.",
"sourceBrowserRunning": "Исходный браузер был запущен, поэтому данные сайтов могут быть неполными.",
"securePreferencesReset": "Защищённые настройки, например домашняя страница и поисковая система, сброшены до значений по умолчанию.",
"extensionsPartial": "Некоторые расширения принадлежали исходному браузеру и не были перенесены.",
"storeUnreadable": "База данных не читалась и была пропущена, а не скопирована повреждённой."
}
},
"syncTooltips": {
"syncing": "Синхронизация...",
@@ -1828,6 +1879,15 @@
"vpnNotFound": "VPN не найден",
"extensionNotFound": "Расширение не найдено",
"extensionGroupNotFound": "Группа расширений не найдена",
"extensionUnsupportedFileType": "Этот тип файла не поддерживается. Расширение должно быть архивом .crx или .zip либо папкой.",
"extensionDirNotFound": "Эта папка больше не существует.",
"extensionNotADirectory": "Указанный путь не является папкой.",
"extensionManifestMissing": "В этой папке нет файла manifest.json. Выберите папку, в которой лежит manifest.json расширения.",
"extensionManifestInvalid": "Не удалось прочитать manifest.json в этой папке.",
"extensionDirTooLarge": "Папка слишком большая, чтобы скопировать её в Donut (не более 256 МБ и 20 000 файлов). Вместо этого свяжите её.",
"extensionPathHasComma": "В пути к папке есть запятая, которую Chromium не может обработать. Переименуйте или переместите папку.",
"extensionLinkRequiresDirectory": "Загружать на месте можно только папку. Отключите связывание, чтобы добавить архив.",
"extensionLinkedCannotSync": "Это расширение загружается из папки на этом компьютере, поэтому синхронизировать нечего.",
"cannotModifyCloudManagedProxy": "Невозможно изменить синхронизацию для облачного прокси",
"syncLockedByProfile": "Невозможно отключить синхронизацию, пока используется синхронизированными профилями",
"syncNotConfigured": "Синхронизация не настроена. Войдите или настройте собственный сервер.",
@@ -1928,7 +1988,10 @@
"malformed": "VLESS URI недействителен."
},
"camoufoxRemoved": "Camoufox больше не поддерживается. Создайте этот профиль заново с Wayfern.",
"noE2ePasswordSet": "Пароль сквозного шифрования не задан. Задайте его перед синхронизацией зашифрованных данных."
"noE2ePasswordSet": "Пароль сквозного шифрования не задан. Задайте его перед синхронизацией зашифрованных данных.",
"importSourceNotChromium": "Эта папка не является профилем браузера на Chromium",
"importSourceNotChromiumNamed": "Профили {{family}} импортировать нельзя: поддерживаются только браузеры на Chromium",
"importSourceBrowserRunning": "Сначала закройте {{browser}} или выберите импорт всё равно"
},
"rail": {
"profiles": "Профили",
@@ -2144,7 +2207,7 @@
},
"wayfernBlocked": {
"title": "Автоматизация браузера приостановлена",
"description": "Доступ вашей учётной записи к Pro-функциям браузера временно ограничен — обычно из-за входа сразу на нескольких устройствах. Выйдите из аккаунта на других устройствах и перезапустите профиль, чтобы восстановить доступ."
"description": "Автоматизация браузера работает только на одном устройстве одновременно, и сейчас её использует другое устройство с активным входом. Выйдите из аккаунта на нём и запустите профиль снова."
},
"wayfernConfigDialog": {
"titleView": "Просмотр настроек отпечатка - {{name}} ({{browser}})",
@@ -2192,7 +2255,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot прогревает ваши профили ночью на удалённой машине, чтобы они сохраняли cookies и историю, пока ваш компьютер выключен. Требуется тариф Pro или Team."
"hint": "Cookie Bot прогревает ваши профили ночью на удалённой машине, чтобы они сохраняли cookies и историю, пока ваш компьютер выключен."
},
"empty": {
"title": "Нет подключённых профилей",
@@ -2484,7 +2547,7 @@
},
"actionBar": {
"enrol": "Подключить к Cookie Bot",
"proRequired": "Для Cookie Bot нужен тариф Pro или Team",
"proRequired": "Для Cookie Bot нужен платный тариф",
"noneEligible": "Ни один из выбранных профилей нельзя прогреть удалённо"
},
"actions": {
@@ -2546,15 +2609,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 +2630,8 @@
"dontWarnExtensions": "Больше не предупреждать об этих расширениях",
"applyToRemaining": "Применить этот выбор к остальным профилям",
"cancelledSummary": "Отменено запусков: {{cancelled}} из {{total}}",
"cancelled": "Запуск отменён",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"vpnExtensionEntryNoVersion": " — {{capability}}, {{source}}",
"scanIncompleteMissing": "Профиль ещё ни разу не запускался, поэтому удалось проверить только расширения, управляемые Donut."
}
}
+80 -14
View File
@@ -635,6 +635,7 @@
"connectionSuccess": "Bağlantı başarılı!",
"serverError": "Sunucu bir hatayla yanıt verdi",
"connectFailed": "Sunucuya bağlanılamadı",
"storageEndpoint": "Depolama: {{endpoint}}",
"settingsSaved": "Eşitleme ayarları kaydedildi",
"saveFailed": "Ayarlar kaydedilemedi",
"disconnected": "Eşitleme bağlantısı kesildi",
@@ -1229,6 +1230,12 @@
"syncing": "Eşitleniyor",
"synced": "Eşitlendi",
"error": "Hata"
},
"ephemeral": {
"cookiesUnavailable": "Geçici profiller tarayıcı kapandığında silinir, bu yüzden burada yönetilecek çerez yoktur.",
"extensionsUnavailable": "Geçici profiller tarayıcı kapandığında silinir, bu yüzden onlara uzantı grubu atanamaz.",
"syncUnavailable": "Geçici profiller tarayıcı kapandığında silinir, bu yüzden buluta eşitlenecek bir şey yoktur.",
"hint": "Bunun kalıcı olmasını istiyorsanız normal bir profil oluşturun."
}
},
"extensions": {
@@ -1264,7 +1271,7 @@
"deleteConfirmDescription": "\"{{name}}\" uzantısını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"deleteGroupConfirmTitle": "Uzantı Grubunu Sil",
"deleteGroupConfirmDescription": "\"{{name}}\" grubunu silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"invalidFileType": "Geçersiz dosya türü. Lütfen bir .crx, .xpi veya .zip dosyası yükleyin.",
"invalidFileType": "Geçersiz dosya türü. Lütfen bir .crx veya .zip dosyası seçin.",
"readError": "Uzantı dosyası okunamadı.",
"assignTitle": "Uzantı Grubu Ata",
"assignDescription": "Seçili {{count}} profili bir uzantı grubuna atayın.",
@@ -1272,7 +1279,6 @@
"assignSuccess": "Uzantı grubu başarıyla atandı",
"editExtension": "Uzantıyı düzenle",
"updateSuccess": "Uzantı başarıyla güncellendi",
"reupload": "Yeniden yükle",
"version": "Sürüm",
"author": "Yazar",
"homepage": "Ana sayfa",
@@ -1280,10 +1286,26 @@
"editGroupDescription": "Grup adını güncelleyin ve gruba dahil uzantıları yönetin.",
"groupExtensions": "Bu gruptaki uzantılar",
"noExtensionsInGroup": "Henüz uzantı eklenmedi",
"editExtensionDescription": "Uzantı adını güncelleyin, üst verileri görüntüleyin veya uzantı dosyasını yeniden yükleyin.",
"editExtensionDescription": "Uzantının adını güncelleyin, meta verilerini görüntüleyin veya başka bir arşiv ya da klasörle değiştirin.",
"metadata": "Üst Veriler",
"noMetadata": "Manifest'te üst veri yok.",
"selectFile": "Dosya Seç",
"loadUnpacked": "Klasörden yükle",
"loadUnpackedTooltip": "manifest.json içeren bir klasörden uzantı yükleyin",
"selectFolderTitle": "Uzantı klasörünü seçin",
"selectedFolder": "Seçilen klasör",
"selectFolder": "Klasör seç",
"linkFolder": "Doğrudan bu klasörden yükle",
"linkFolderOff": "Klasör Donut'a kopyalanır. Uzantı taşınabilir olur ve diğer cihazlarınızla eşitlenir.",
"linkFolderOn": "Donut her başlatmada uzantıyı doğrudan bu klasörden yükler. Değişiklikleriniz tarayıcının bir sonraki açılışında geçerli olur, ancak uzantı yalnızca bu bilgisayarda kalır ve hiçbir zaman eşitlenmez.",
"replaceSource": "Kaynağı değiştir",
"linkedNoSync": "Bağlı uzantılar yalnızca bu bilgisayarda kalır ve eşitlenemez.",
"uploadFailed": "Uzantı eklenemedi",
"updateFailed": "Uzantı güncellenemedi",
"deleteFailed": "Uzantı silinemedi",
"groupCreateFailed": "Uzantı grubu oluşturulamadı",
"groupUpdateFailed": "Uzantı grubu güncellenemedi",
"groupDeleteFailed": "Uzantı grubu silinemedi",
"syncEnabled": "Eşitleme etkinleştirildi",
"syncDisabled": "Eşitleme devre dışı bırakıldı",
"syncEnableTooltip": "Eşitlemeyi etkinleştir",
@@ -1296,6 +1318,14 @@
"groupsTitle": "Uzantı gruplarını sil",
"groupsDescription": "{{count}} uzantı grubu silinsin mi? {{names}}",
"confirmButton": "Sil"
},
"source": {
"label": "Kaynak",
"archive": "Arşiv",
"unpacked": "Paketlenmemiş klasör",
"linked": "Bağlı klasör",
"folderLabel": "Klasör",
"linkedTooltip": "Doğrudan {{path}} konumundan yükleniyor"
}
},
"pro": {
@@ -1467,7 +1497,28 @@
"vpnOptional": "VPN (isteğe bağlı)",
"noVpn": "VPN yok",
"advancedOptions": "Gelişmiş seçenekler",
"configureFingerprint": "Parmak izini yapılandır (isteğe bağlı)"
"configureFingerprint": "Parmak izini yapılandır (isteğe bağlı)",
"reportCookies": "Çerezler",
"reportPasswords": "Parolalar",
"reportAutofill": "Ödeme yöntemleri",
"reportExtensions": "Uzantılar",
"reportHistory": "Geçmiş",
"reportBookmarks": "Yer imleri",
"reportLocalStorage": "Site verileri",
"reportNothingCarried": "Okunabilir hiçbir veri aktarılmadı",
"reportUnrecoverable": "Şifresi çözülemedi: {{count}}",
"closeSourceBrowserHint": "Tam bir kopya için kaynak tarayıcıyı kapatıp yeniden deneyin ya da site verilerinin eksik olabileceğini kabul ederek şimdi içe aktarın.",
"importAnyway": "Yine de içe aktar",
"warnings": {
"secretsNotMigrated": "Çerezlerin ve parolaların kilidi açılamadı, bu yüzden yeniden oturum açmanız gerekecek.",
"appBoundEncrypted": "Windows'ta Chrome 127+ çerezleri tarayıcının kendisine bağlar; başka hiçbir uygulama bunları taşıyamaz.",
"storeTooOld": "Bir veritabanı bu tarayıcının açamayacağı kadar eskiydi ve atlandı.",
"storeTooNew": "Bir veritabanı bundan daha yeni bir tarayıcıdan geldi ve atlandı.",
"sourceBrowserRunning": "Kaynak tarayıcı çalışıyordu, bu yüzden site verileri eksik olabilir.",
"securePreferencesReset": "Ana sayfa ve arama motoru gibi korumalı ayarlar varsayılana döndü.",
"extensionsPartial": "Bazı uzantılar kaynak tarayıcıya aitti ve aktarılmadı.",
"storeUnreadable": "Bir veritabanı okunamadı ve bozuk şekilde kopyalanmak yerine atlandı."
}
},
"syncTooltips": {
"syncing": "Eşitleniyor...",
@@ -1814,6 +1865,15 @@
"vpnNotFound": "VPN bulunamadı",
"extensionNotFound": "Uzantı bulunamadı",
"extensionGroupNotFound": "Uzantı grubu bulunamadı",
"extensionUnsupportedFileType": "Bu dosya türü desteklenmiyor. Bir uzantı .crx ya da .zip arşivi veya bir klasör olmalıdır.",
"extensionDirNotFound": "Bu klasör artık mevcut değil.",
"extensionNotADirectory": "Bu yol bir klasör değil.",
"extensionManifestMissing": "Bu klasörde manifest.json yok. Uzantının manifest.json dosyasını içeren klasörü seçin.",
"extensionManifestInvalid": "Bu klasördeki manifest.json okunamadı.",
"extensionDirTooLarge": "Bu klasör Donut'a kopyalanamayacak kadar büyük (sınır: 256 MB ve 20.000 dosya). Bunun yerine klasörü bağlayın.",
"extensionPathHasComma": "Bu klasörün yolunda virgül var ve Chromium bunu yükleyemez. Klasörü yeniden adlandırın veya taşıyın.",
"extensionLinkRequiresDirectory": "Yerinde yalnızca bir klasör yüklenebilir. Arşiv eklemek için bağlamayı kapatın.",
"extensionLinkedCannotSync": "Bu uzantı, bu bilgisayardaki bir klasörden yükleniyor; eşitlenecek bir şey yok.",
"cannotModifyCloudManagedProxy": "Bulut tarafından yönetilen bir proxy'nin eşitlemesi değiştirilemez",
"syncLockedByProfile": "Eşitlenen profiller tarafından kullanılırken eşitleme devre dışı bırakılamaz",
"syncNotConfigured": "Eşitleme yapılandırılmadı. Önce oturum açın veya kendi sunucunuzu yapılandırın.",
@@ -1914,7 +1974,10 @@
"malformed": "VLESS URI'si geçersiz."
},
"camoufoxRemoved": "Camoufox artık desteklenmiyor. Bu profili Wayfern ile yeniden oluşturun.",
"noE2ePasswordSet": "Uçtan uca şifreleme parolası ayarlanmamış. Şifreli veriyi eşitlemeden önce bir parola belirleyin."
"noE2ePasswordSet": "Uçtan uca şifreleme parolası ayarlanmamış. Şifreli veriyi eşitlemeden önce bir parola belirleyin.",
"importSourceNotChromium": "Bu klasör bir Chromium tarayıcı profili değil",
"importSourceNotChromiumNamed": "{{family}} profilleri içe aktarılamaz; yalnızca Chromium tabanlı tarayıcılar desteklenir",
"importSourceBrowserRunning": "Önce {{browser}} uygulamasını kapatın veya yine de içe aktarmayı seçin"
},
"rail": {
"profiles": "Profiller",
@@ -2130,7 +2193,7 @@
},
"wayfernBlocked": {
"title": "Tarayıcı otomasyonu duraklatıldı",
"description": "Hesabınız, genellikle aynı anda birden fazla cihazda oturum açmaktan kaynaklanan bir nedenle Pro tarayıcı özelliklerinden geçici olarak kısıtlandı. Diğer cihazlardaki oturumları kapatın, ardından geri yüklemek için profili yeniden başlatın."
"description": "Tarayıcı otomasyonu aynı anda yalnızca tek bir cihazda çalışır ve şu anda oturum açmış başka bir cihaz tarafından kullanılıyor. O cihazdan çıkış yapın, ardından profili yeniden başlatın."
},
"wayfernConfigDialog": {
"titleView": "Parmak İzi Ayarlarını Görüntüle - {{name}} ({{browser}})",
@@ -2178,7 +2241,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."
},
"empty": {
"title": "Kayıtlı profil yok",
@@ -2428,7 +2491,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 +2551,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 +2572,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."
}
}
+80 -14
View File
@@ -635,6 +635,7 @@
"connectionSuccess": "Kết nối thành công!",
"serverError": "Máy chủ trả về lỗi",
"connectFailed": "Kết nối máy chủ thất bại",
"storageEndpoint": "Bộ nhớ: {{endpoint}}",
"settingsSaved": "Đã lưu cài đặt đồng bộ",
"saveFailed": "Lưu cài đặt thất bại",
"disconnected": "Đã ngắt kết nối đồng bộ",
@@ -1229,6 +1230,12 @@
"syncing": "Đang đồng bộ",
"synced": "Đã đồng bộ",
"error": "Lỗi"
},
"ephemeral": {
"cookiesUnavailable": "Hồ sơ tạm thời bị xoá khi đóng trình duyệt, nên ở đây không có cookie nào để quản lý.",
"extensionsUnavailable": "Hồ sơ tạm thời bị xoá khi đóng trình duyệt, nên không thể gán nhóm tiện ích mở rộng cho chúng.",
"syncUnavailable": "Hồ sơ tạm thời bị xoá khi đóng trình duyệt, nên không có gì để đồng bộ lên đám mây.",
"hint": "Hãy tạo hồ sơ thường nếu bạn cần giữ lại dữ liệu này."
}
},
"extensions": {
@@ -1264,7 +1271,7 @@
"deleteConfirmDescription": "Bạn có chắc muốn xóa \"{{name}}\"? Hành động này không thể hoàn tác.",
"deleteGroupConfirmTitle": "Xóa nhóm tiện ích",
"deleteGroupConfirmDescription": "Bạn có chắc muốn xóa nhóm \"{{name}}\"? Hành động này không thể hoàn tác.",
"invalidFileType": "Loại tệp không hợp lệ. Vui lòng tải lên tệp .crx, .xpi hoặc .zip.",
"invalidFileType": "Loại tệp không hợp lệ. Vui lòng chọn tệp .crx hoặc .zip.",
"readError": "Đọc tệp tiện ích thất bại.",
"assignTitle": "Gán nhóm tiện ích",
"assignDescription": "Gán {{count}} profile đã chọn vào nhóm tiện ích.",
@@ -1272,7 +1279,6 @@
"assignSuccess": "Gán nhóm tiện ích thành công",
"editExtension": "Chỉnh sửa tiện ích",
"updateSuccess": "Cập nhật tiện ích thành công",
"reupload": "Tải lên lại",
"version": "Phiên bản",
"author": "Tác giả",
"homepage": "Trang chủ",
@@ -1280,10 +1286,26 @@
"editGroupDescription": "Cập nhật tên nhóm và quản lý tiện ích trong nhóm.",
"groupExtensions": "Tiện ích trong nhóm này",
"noExtensionsInGroup": "Chưa thêm tiện ích nào",
"editExtensionDescription": "Cập nhật tên tiện ích, xem metadata hoặc tải lên lại tệp tiện ích.",
"editExtensionDescription": "Cập nhật tên tiện ích, xem siêu dữ liệu hoặc thay bằng tệp nén hay thư mục khác.",
"metadata": "Metadata",
"noMetadata": "Không có metadata từ manifest.",
"selectFile": "Chọn tệp",
"loadUnpacked": "Tải từ thư mục",
"loadUnpackedTooltip": "Tải tiện ích từ thư mục có chứa manifest.json",
"selectFolderTitle": "Chọn thư mục tiện ích",
"selectedFolder": "Thư mục đã chọn",
"selectFolder": "Chọn thư mục",
"linkFolder": "Tải trực tiếp từ thư mục này",
"linkFolderOff": "Thư mục được sao chép vào Donut. Tiện ích có thể mang đi và đồng bộ sang các thiết bị khác của bạn.",
"linkFolderOn": "Donut tải tiện ích trực tiếp từ thư mục này mỗi lần khởi chạy. Các thay đổi của bạn có hiệu lực ở lần mở trình duyệt tiếp theo, nhưng tiện ích chỉ nằm trên máy này và không bao giờ đồng bộ.",
"replaceSource": "Thay nguồn",
"linkedNoSync": "Tiện ích được liên kết chỉ nằm trên máy này nên không thể đồng bộ.",
"uploadFailed": "Không thêm được tiện ích",
"updateFailed": "Không cập nhật được tiện ích",
"deleteFailed": "Không xóa được tiện ích",
"groupCreateFailed": "Không tạo được nhóm tiện ích",
"groupUpdateFailed": "Không cập nhật được nhóm tiện ích",
"groupDeleteFailed": "Không xóa được nhóm tiện ích",
"syncEnabled": "Đã bật đồng bộ",
"syncDisabled": "Đã tắt đồng bộ",
"syncEnableTooltip": "Bật đồng bộ",
@@ -1296,6 +1318,14 @@
"groupsTitle": "Xóa nhóm tiện ích",
"groupsDescription": "Xóa {{count}} nhóm tiện ích? {{names}}",
"confirmButton": "Xóa"
},
"source": {
"label": "Nguồn",
"archive": "Tệp nén",
"unpacked": "Thư mục đã giải nén",
"linked": "Thư mục liên kết",
"folderLabel": "Thư mục",
"linkedTooltip": "Tải trực tiếp từ {{path}}"
}
},
"pro": {
@@ -1467,7 +1497,28 @@
"vpnOptional": "VPN (tùy chọn)",
"noVpn": "Không dùng VPN",
"advancedOptions": "Tùy chọn nâng cao",
"configureFingerprint": "Cấu hình vân tay (tùy chọn)"
"configureFingerprint": "Cấu hình vân tay (tùy chọn)",
"reportCookies": "Cookie",
"reportPasswords": "Mật khẩu",
"reportAutofill": "Phương thức thanh toán",
"reportExtensions": "Tiện ích mở rộng",
"reportHistory": "Lịch sử",
"reportBookmarks": "Dấu trang",
"reportLocalStorage": "Dữ liệu trang web",
"reportNothingCarried": "Không có dữ liệu đọc được nào được chuyển sang",
"reportUnrecoverable": "Không giải mã được: {{count}}",
"closeSourceBrowserHint": "Hãy đóng trình duyệt nguồn rồi thử lại để có bản sao đầy đủ, hoặc nhập ngay và chấp nhận rằng dữ liệu trang web có thể chưa đầy đủ.",
"importAnyway": "Vẫn nhập",
"warnings": {
"secretsNotMigrated": "Không mở khóa được cookie và mật khẩu, nên bạn sẽ phải đăng nhập lại.",
"appBoundEncrypted": "Chrome 127 trở lên trên Windows gắn cookie với chính trình duyệt; không ứng dụng nào khác có thể chuyển được.",
"storeTooOld": "Một cơ sở dữ liệu quá cũ để trình duyệt này mở nên đã bị bỏ qua.",
"storeTooNew": "Một cơ sở dữ liệu đến từ trình duyệt mới hơn nên đã bị bỏ qua.",
"sourceBrowserRunning": "Trình duyệt nguồn đang chạy nên dữ liệu trang web có thể chưa đầy đủ.",
"securePreferencesReset": "Các cài đặt được bảo vệ như trang chủ và công cụ tìm kiếm đã trở về mặc định.",
"extensionsPartial": "Một số tiện ích thuộc về trình duyệt nguồn nên không được chuyển sang.",
"storeUnreadable": "Một cơ sở dữ liệu không đọc được nên đã bị bỏ qua thay vì sao chép hỏng."
}
},
"syncTooltips": {
"syncing": "Đang đồng bộ...",
@@ -1814,6 +1865,15 @@
"vpnNotFound": "Không tìm thấy VPN",
"extensionNotFound": "Không tìm thấy tiện ích",
"extensionGroupNotFound": "Không tìm thấy nhóm tiện ích",
"extensionUnsupportedFileType": "Loại tệp này không được hỗ trợ. Tiện ích phải là tệp nén .crx hoặc .zip, hoặc một thư mục.",
"extensionDirNotFound": "Thư mục đó không còn tồn tại.",
"extensionNotADirectory": "Đường dẫn đó không phải là thư mục.",
"extensionManifestMissing": "Thư mục đó không có manifest.json. Hãy chọn thư mục chứa manifest.json của tiện ích.",
"extensionManifestInvalid": "Không đọc được manifest.json trong thư mục đó.",
"extensionDirTooLarge": "Thư mục đó quá lớn để sao chép vào Donut (giới hạn là 256 MB và 20.000 tệp). Hãy liên kết thư mục thay vì sao chép.",
"extensionPathHasComma": "Đường dẫn thư mục có dấu phẩy nên Chromium không tải được. Hãy đổi tên hoặc di chuyển thư mục.",
"extensionLinkRequiresDirectory": "Chỉ có thể tải tại chỗ một thư mục. Hãy tắt liên kết để thêm tệp nén.",
"extensionLinkedCannotSync": "Tiện ích này được tải từ một thư mục trên máy này nên không có gì để đồng bộ.",
"cannotModifyCloudManagedProxy": "Không thể chỉnh sửa đồng bộ cho proxy được quản lý bởi đám mây",
"syncLockedByProfile": "Không thể tắt đồng bộ khi đang được sử dụng bởi profile đã đồng bộ",
"syncNotConfigured": "Chưa cấu hình đồng bộ. Đăng nhập hoặc cấu hình máy chủ tự lưu trữ trước.",
@@ -1914,7 +1974,10 @@
"malformed": "URI VLESS không hợp lệ."
},
"camoufoxRemoved": "Camoufox không còn được hỗ trợ. Hãy tạo lại hồ sơ này bằng Wayfern.",
"noE2ePasswordSet": "Chưa đặt mật khẩu mã hóa đầu cuối. Hãy đặt trước khi đồng bộ dữ liệu đã mã hóa."
"noE2ePasswordSet": "Chưa đặt mật khẩu mã hóa đầu cuối. Hãy đặt trước khi đồng bộ dữ liệu đã mã hóa.",
"importSourceNotChromium": "Thư mục này không phải hồ sơ trình duyệt Chromium",
"importSourceNotChromiumNamed": "Không thể nhập hồ sơ {{family}}; chỉ hỗ trợ các trình duyệt nền Chromium",
"importSourceBrowserRunning": "Hãy đóng {{browser}} trước, hoặc chọn vẫn nhập"
},
"rail": {
"profiles": "Profile",
@@ -2130,7 +2193,7 @@
},
"wayfernBlocked": {
"title": "Tự động hóa trình duyệt đã tạm dừng",
"description": "Tài khoản của bạn tạm thời bị hạn chế các tính năng Pro của trình duyệt, thường do đăng nhập trên nhiều thiết bị cùng lúc. Hãy đăng xuất khỏi các thiết bị khác rồi khởi chạy lại profile để khôi phục."
"description": "Tự động hóa trình duyệt chỉ chạy trên một thiết bị tại một thời điểm và một thiết bị khác đang đăng nhập hiện đang sử dụng nó. Hãy đăng xuất trên thiết bị đó, rồi khởi chạy lại cấu hình."
},
"wayfernConfigDialog": {
"titleView": "Xem cài đặt vân tay - {{name}} ({{browser}})",
@@ -2178,7 +2241,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."
},
"empty": {
"title": "Chưa có hồ sơ nào được đăng ký",
@@ -2428,7 +2491,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 +2551,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 +2572,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ý."
}
}
+80 -14
View File
@@ -635,6 +635,7 @@
"connectionSuccess": "连接成功!",
"serverError": "服务器返回了错误",
"connectFailed": "连接服务器失败",
"storageEndpoint": "存储: {{endpoint}}",
"settingsSaved": "同步设置已保存",
"saveFailed": "保存设置失败",
"disconnected": "已断开同步",
@@ -1229,6 +1230,12 @@
"syncing": "同步中",
"synced": "已同步",
"error": "错误"
},
"ephemeral": {
"cookiesUnavailable": "临时配置在浏览器关闭时会被丢弃,因此这里没有可管理的 Cookie。",
"extensionsUnavailable": "临时配置在浏览器关闭时会被丢弃,因此无法为其分配扩展分组。",
"syncUnavailable": "临时配置在浏览器关闭时会被丢弃,因此没有可同步到云端的内容。",
"hint": "如果需要保留这些数据,请创建普通配置。"
}
},
"extensions": {
@@ -1264,7 +1271,7 @@
"deleteConfirmDescription": "确定要删除「{{name}}」吗?此操作无法撤消。",
"deleteGroupConfirmTitle": "删除扩展程序组",
"deleteGroupConfirmDescription": "确定要删除分组「{{name}}」吗?此操作无法撤消。",
"invalidFileType": "无效的文件类型。请上传 .crx、.xpi 或 .zip 文件。",
"invalidFileType": "文件类型无效。请选择 .crx 或 .zip 文件。",
"readError": "读取扩展程序文件失败。",
"assignTitle": "分配扩展程序组",
"assignDescription": "将 {{count}} 个选定的配置文件分配到扩展程序组。",
@@ -1272,7 +1279,6 @@
"assignSuccess": "扩展程序组分配成功",
"editExtension": "编辑扩展",
"updateSuccess": "扩展更新成功",
"reupload": "重新上传",
"version": "版本",
"author": "作者",
"homepage": "主页",
@@ -1280,10 +1286,26 @@
"editGroupDescription": "更新分组名称并管理包含的扩展。",
"groupExtensions": "此分组中的扩展",
"noExtensionsInGroup": "尚未添加扩展",
"editExtensionDescription": "更新扩展名称、查看元数据或重新上传扩展文件。",
"editExtensionDescription": "修改扩展名称、查看元数据,或用其他压缩包或文件夹替换它。",
"metadata": "元数据",
"noMetadata": "清单中没有可用的元数据。",
"selectFile": "选择文件",
"loadUnpacked": "加载文件夹",
"loadUnpackedTooltip": "从包含 manifest.json 的文件夹加载扩展",
"selectFolderTitle": "选择扩展文件夹",
"selectedFolder": "已选文件夹",
"selectFolder": "选择文件夹",
"linkFolder": "直接从该文件夹加载",
"linkFolderOff": "文件夹会复制到 Donut,扩展可随身携带并同步到你的其他设备。",
"linkFolderOn": "Donut 每次启动都直接从该文件夹加载扩展。你的修改会在下次启动浏览器时生效,但扩展只保留在本机,不会同步。",
"replaceSource": "替换来源",
"linkedNoSync": "已链接的扩展只保留在本机,无法同步。",
"uploadFailed": "添加扩展失败",
"updateFailed": "更新扩展失败",
"deleteFailed": "删除扩展失败",
"groupCreateFailed": "创建扩展组失败",
"groupUpdateFailed": "更新扩展组失败",
"groupDeleteFailed": "删除扩展组失败",
"syncEnabled": "同步已启用",
"syncDisabled": "同步已禁用",
"syncEnableTooltip": "启用同步",
@@ -1296,6 +1318,14 @@
"groupsTitle": "删除扩展组",
"groupsDescription": "删除 {{count}} 个扩展组?{{names}}",
"confirmButton": "删除"
},
"source": {
"label": "来源",
"archive": "压缩包",
"unpacked": "解压文件夹",
"linked": "链接文件夹",
"folderLabel": "文件夹",
"linkedTooltip": "直接从 {{path}} 加载"
}
},
"pro": {
@@ -1467,7 +1497,28 @@
"vpnOptional": "VPN(可选)",
"noVpn": "不使用 VPN",
"advancedOptions": "高级选项",
"configureFingerprint": "配置指纹(可选)"
"configureFingerprint": "配置指纹(可选)",
"reportCookies": "Cookie",
"reportPasswords": "密码",
"reportAutofill": "付款方式",
"reportExtensions": "扩展程序",
"reportHistory": "历史记录",
"reportBookmarks": "书签",
"reportLocalStorage": "网站数据",
"reportNothingCarried": "没有可读取的数据被迁移",
"reportUnrecoverable": "无法解密:{{count}}",
"closeSourceBrowserHint": "关闭源浏览器后重试可获得完整副本;也可以现在导入,但网站数据可能不完整。",
"importAnyway": "仍要导入",
"warnings": {
"secretsNotMigrated": "无法解锁 Cookie 和密码,你需要重新登录。",
"appBoundEncrypted": "Windows 上的 Chrome 127+ 会把 Cookie 绑定到浏览器本身,其他任何应用都无法迁移。",
"storeTooOld": "某个数据库过旧,此浏览器无法打开,已跳过。",
"storeTooNew": "某个数据库来自更新版本的浏览器,已跳过。",
"sourceBrowserRunning": "源浏览器正在运行,网站数据可能不完整。",
"securePreferencesReset": "主页、搜索引擎等受保护的设置已恢复为默认值。",
"extensionsPartial": "部分扩展属于源浏览器,未被迁移。",
"storeUnreadable": "某个数据库无法读取,已跳过而不是复制损坏的副本。"
}
},
"syncTooltips": {
"syncing": "同步中...",
@@ -1814,6 +1865,15 @@
"vpnNotFound": "未找到 VPN",
"extensionNotFound": "未找到扩展",
"extensionGroupNotFound": "未找到扩展分组",
"extensionUnsupportedFileType": "不支持该文件类型。扩展必须是 .crx 或 .zip 压缩包,或者一个文件夹。",
"extensionDirNotFound": "该文件夹已不存在。",
"extensionNotADirectory": "该路径不是文件夹。",
"extensionManifestMissing": "该文件夹中没有 manifest.json。请选择包含扩展 manifest.json 的文件夹。",
"extensionManifestInvalid": "无法读取该文件夹中的 manifest.json。",
"extensionDirTooLarge": "该文件夹过大,无法复制到 Donut(上限为 256 MB、20,000 个文件)。请改用链接方式加载。",
"extensionPathHasComma": "该文件夹路径中包含逗号,Chromium 无法加载。请重命名或移动该文件夹。",
"extensionLinkRequiresDirectory": "只有文件夹才能就地加载。要添加压缩包,请关闭链接选项。",
"extensionLinkedCannotSync": "该扩展是从本机文件夹加载的,没有需要同步的内容。",
"cannotModifyCloudManagedProxy": "无法修改云管理代理的同步",
"syncLockedByProfile": "在被已同步的配置文件使用时无法禁用同步",
"syncNotConfigured": "同步未配置。请先登录或配置自托管服务器。",
@@ -1914,7 +1974,10 @@
"malformed": "VLESS URI 无效。"
},
"camoufoxRemoved": "Camoufox 已不再受支持。请使用 Wayfern 重新创建此配置文件。",
"noE2ePasswordSet": "尚未设置端到端加密密码。请先设置后再同步加密数据。"
"noE2ePasswordSet": "尚未设置端到端加密密码。请先设置后再同步加密数据。",
"importSourceNotChromium": "该文件夹不是 Chromium 浏览器配置文件",
"importSourceNotChromiumNamed": "无法导入 {{family}} 配置文件;仅支持基于 Chromium 的浏览器",
"importSourceBrowserRunning": "请先关闭 {{browser}},或选择仍要导入"
},
"rail": {
"profiles": "配置文件",
@@ -2130,7 +2193,7 @@
},
"wayfernBlocked": {
"title": "浏览器自动化已暂停",
"description": "您的账户暂时被限制使用 Pro 浏览器功能,通常是因为同时在多台设备上登录。请退出其他设备的登录,然后重新启动配置文件即可恢复。"
"description": "浏览器自动化一次只能在一台设备上运行,目前有另一台已登录的设备正在使用。请在该设备上退出登录,然后重新启动配置文件。"
},
"wayfernConfigDialog": {
"titleView": "查看指纹设置 - {{name}} ({{browser}})",
@@ -2178,7 +2241,7 @@
},
"locked": {
"title": "Cookie Bot",
"hint": "Cookie Bot 在远程机器上通宵养号,无需开着你的电脑也能保住 Cookie 和历史记录。需要 Pro 或 Team 套餐。"
"hint": "Cookie Bot 在远程机器上通宵养号,无需开着你的电脑也能保住 Cookie 和历史记录。"
},
"empty": {
"title": "尚未加入任何配置文件",
@@ -2428,7 +2491,7 @@
},
"actionBar": {
"enrol": "加入 Cookie Bot",
"proRequired": "Cookie Bot 需要 Pro 或 Team 套餐",
"proRequired": "Cookie Bot 需要付费套餐",
"noneEligible": "所选配置文件都无法远程养号"
},
"actions": {
@@ -2488,15 +2551,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 +2572,8 @@
"dontWarnExtensions": "不再就这些扩展发出警告",
"applyToRemaining": "将此选择应用于其余配置文件",
"cancelledSummary": "已取消 {{total}} 次启动中的 {{cancelled}} 次",
"cancelled": "已取消启动",
"vpnExtensionEntry": " {{version}} — {{capability}}、{{source}}",
"vpnExtensionEntryNoVersion": " — {{capability}}、{{source}}",
"scanIncompleteMissing": "此配置文件尚未启动过,因此只能检查由 Donut 管理的扩展。"
}
}
+39
View File
@@ -28,6 +28,15 @@ export type BackendErrorCode =
| "VPN_NOT_FOUND"
| "EXTENSION_NOT_FOUND"
| "EXTENSION_GROUP_NOT_FOUND"
| "EXTENSION_UNSUPPORTED_FILE_TYPE"
| "EXTENSION_DIR_NOT_FOUND"
| "EXTENSION_NOT_A_DIRECTORY"
| "EXTENSION_MANIFEST_MISSING"
| "EXTENSION_MANIFEST_INVALID"
| "EXTENSION_DIR_TOO_LARGE"
| "EXTENSION_PATH_HAS_COMMA"
| "EXTENSION_LINK_REQUIRES_DIRECTORY"
| "EXTENSION_LINKED_CANNOT_SYNC"
| "CANNOT_MODIFY_CLOUD_MANAGED_PROXY"
| "SYNC_LOCKED_BY_PROFILE"
| "SYNC_NOT_CONFIGURED"
@@ -43,6 +52,8 @@ export type BackendErrorCode =
| "UPDATE_PREPARATION_FAILED"
| "PROFILE_NAME_EXISTS"
| "IMPORT_SOURCE_NOT_FOUND"
| "IMPORT_SOURCE_NOT_CHROMIUM"
| "IMPORT_SOURCE_BROWSER_RUNNING"
| "IMPORT_NO_ITEMS"
| "BROWSER_NOT_DOWNLOADED"
| "ARCHIVE_EXTRACTION_FAILED"
@@ -217,6 +228,24 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.extensionNotFound");
case "EXTENSION_GROUP_NOT_FOUND":
return t("backendErrors.extensionGroupNotFound");
case "EXTENSION_UNSUPPORTED_FILE_TYPE":
return t("backendErrors.extensionUnsupportedFileType");
case "EXTENSION_DIR_NOT_FOUND":
return t("backendErrors.extensionDirNotFound");
case "EXTENSION_NOT_A_DIRECTORY":
return t("backendErrors.extensionNotADirectory");
case "EXTENSION_MANIFEST_MISSING":
return t("backendErrors.extensionManifestMissing");
case "EXTENSION_MANIFEST_INVALID":
return t("backendErrors.extensionManifestInvalid");
case "EXTENSION_DIR_TOO_LARGE":
return t("backendErrors.extensionDirTooLarge");
case "EXTENSION_PATH_HAS_COMMA":
return t("backendErrors.extensionPathHasComma");
case "EXTENSION_LINK_REQUIRES_DIRECTORY":
return t("backendErrors.extensionLinkRequiresDirectory");
case "EXTENSION_LINKED_CANNOT_SYNC":
return t("backendErrors.extensionLinkedCannotSync");
case "CANNOT_MODIFY_CLOUD_MANAGED_PROXY":
return t("backendErrors.cannotModifyCloudManagedProxy");
case "SYNC_LOCKED_BY_PROFILE":
@@ -253,6 +282,16 @@ export function translateBackendError(t: TFunction, err: unknown): string {
});
case "IMPORT_SOURCE_NOT_FOUND":
return t("backendErrors.importSourceNotFound");
case "IMPORT_SOURCE_NOT_CHROMIUM":
return parsed.params?.family
? t("backendErrors.importSourceNotChromiumNamed", {
family: parsed.params.family,
})
: t("backendErrors.importSourceNotChromium");
case "IMPORT_SOURCE_BROWSER_RUNNING":
return t("backendErrors.importSourceBrowserRunning", {
browser: parsed.params?.browser ?? "",
});
case "IMPORT_NO_ITEMS":
return t("backendErrors.importNoItems");
case "BROWSER_NOT_DOWNLOADED":
+129
View File
@@ -0,0 +1,129 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
CREDENTIALS_FIRST_FORMAT,
HOST_FIRST_FORMAT,
pickParsedProxy,
resolveAmbiguousProxyLine,
splitProxyScheme,
} from "./proxy-string.ts";
/**
* The formats themselves are exercised in Rust
* (`proxy_manager::tests::test_proxy_txt_parsing_various_formats`). What is
* pinned here is the frontend's half: the scheme survives an ambiguous line,
* and a resolution that doesn't fit the line is refused rather than turned into
* a proxy pointing at somebody's password.
*/
test("a bare line is HTTP", () => {
assert.deepEqual(splitProxyScheme("1.2.3.4:8080"), {
proxyType: "http",
rest: "1.2.3.4:8080",
});
});
test("known schemes are recognised and normalised", () => {
assert.deepEqual(splitProxyScheme("SOCKS://1.2.3.4:1080"), {
proxyType: "socks5",
rest: "1.2.3.4:1080",
});
assert.equal(splitProxyScheme("shadowsocks://host:8388").proxyType, "ss");
});
test("an unknown scheme is left in the body rather than guessed at", () => {
assert.deepEqual(splitProxyScheme("ftp://1.2.3.4:21"), {
proxyType: "http",
rest: "ftp://1.2.3.4:21",
});
});
test("host-first resolution keeps the scheme", () => {
assert.deepEqual(
resolveAmbiguousProxyLine(
"socks5://1234:5678:9012:3456",
HOST_FIRST_FORMAT,
),
{
proxy_type: "socks5",
host: "1234",
port: 5678,
username: "9012",
password: "3456",
original_line: "socks5://1234:5678:9012:3456",
},
);
});
test("credentials-first resolution reads the tail as the endpoint", () => {
assert.deepEqual(
resolveAmbiguousProxyLine("1234:5678:9012:3456", CREDENTIALS_FIRST_FORMAT),
{
proxy_type: "http",
host: "9012",
port: 3456,
username: "1234",
password: "5678",
original_line: "1234:5678:9012:3456",
},
);
});
test("a format that doesn't fit the line resolves to nothing", () => {
// 70000 is past the port range, so this ordering cannot be the right one.
assert.equal(
resolveAmbiguousProxyLine("host:70000:user:pass", HOST_FIRST_FORMAT),
null,
);
assert.equal(resolveAmbiguousProxyLine("host:8080", HOST_FIRST_FORMAT), null);
assert.equal(
resolveAmbiguousProxyLine("a:1:b:2", "host:port:user:password"),
null,
);
});
test("the first parsed line of a multi-line paste wins", () => {
const parsed = pickParsedProxy([
{ status: "invalid", line: "notaproxy", reason: "nope" },
{
status: "parsed",
proxy_type: "socks5",
host: "1.2.3.4",
port: 1080,
username: "u",
password: "p",
original_line: "socks5://u:p@1.2.3.4:1080",
},
{
status: "parsed",
proxy_type: "http",
host: "5.6.7.8",
port: 80,
original_line: "5.6.7.8:80",
},
]);
assert.equal(parsed?.host, "1.2.3.4");
assert.equal(parsed?.proxy_type, "socks5");
});
test("an ambiguous paste falls back to host:port:username:password", () => {
const parsed = pickParsedProxy([
{
status: "ambiguous",
line: "1234:5678:9012:3456",
possible_formats: [HOST_FIRST_FORMAT, CREDENTIALS_FIRST_FORMAT],
},
]);
assert.equal(parsed?.host, "1234");
assert.equal(parsed?.port, 5678);
});
test("nothing usable yields null so the plain paste stands", () => {
assert.equal(
pickParsedProxy([
{ status: "invalid", line: "proxy.example.com", reason: "" },
]),
null,
);
assert.equal(pickParsedProxy([]), null);
});
+127
View File
@@ -0,0 +1,127 @@
/**
* Reading a proxy out of a pasted line.
*
* The parser itself is Rust's `parse_txt_proxies`
* (`src-tauri/src/proxy_manager.rs`); both the import dialog and the add/edit
* form hand their clipboard text to it rather than re-implementing the format
* zoo. What is left for the frontend is the part the backend deliberately
* refuses to decide: `a:b:c:d` is either `host:port:username:password` or
* `username:password:host:port`, and when both middle fields parse as a port
* only the user knows which. The backend reports that as `ambiguous`; the
* functions below turn the user's answer back into a proxy.
*
* Kept free of runtime imports so `proxy-string.test.mjs` can load it directly.
*/
import type { ParsedProxyLine, ProxyParseResult } from "@/types";
/** URL schemes the Rust parser accepts, mapped onto the stored proxy type. */
const PROXY_SCHEMES: Record<string, string> = {
http: "http",
https: "https",
socks: "socks5",
socks4: "socks4",
socks5: "socks5",
ss: "ss",
shadowsocks: "ss",
vless: "vless",
};
/** What a line carrying no scheme is assumed to be. */
export const DEFAULT_PROXY_TYPE = "http";
export const HOST_FIRST_FORMAT = "host:port:username:password";
export const CREDENTIALS_FIRST_FORMAT = "username:password:host:port";
/**
* Separates `socks5://1.2.3.4:1080` into its scheme and body. An unknown or
* absent scheme leaves the body untouched and falls back to HTTP, which is what
* the backend does with a bare `host:port`.
*/
export function splitProxyScheme(line: string): {
proxyType: string;
rest: string;
} {
const separator = line.indexOf("://");
if (separator === -1) {
return { proxyType: DEFAULT_PROXY_TYPE, rest: line };
}
const proxyType = PROXY_SCHEMES[line.slice(0, separator).toLowerCase()];
return proxyType
? { proxyType, rest: line.slice(separator + 3) }
: { proxyType: DEFAULT_PROXY_TYPE, rest: line };
}
/**
* Builds a proxy from a four-part line once the user has said which of the two
* orderings it uses. Returns null when the chosen ordering doesn't actually fit
* the line, so a stale selection can't produce a proxy pointing at a password.
*/
export function resolveAmbiguousProxyLine(
line: string,
format: string,
): ParsedProxyLine | null {
const trimmed = line.trim();
const { proxyType, rest } = splitProxyScheme(trimmed);
const parts = rest.split(":");
if (parts.length !== 4) {
return null;
}
const hostFirst = format === HOST_FIRST_FORMAT;
if (!hostFirst && format !== CREDENTIALS_FIRST_FORMAT) {
return null;
}
const host = hostFirst ? parts[0] : parts[2];
const port = Number.parseInt(hostFirst ? parts[1] : parts[3], 10);
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
return null;
}
return {
proxy_type: proxyType,
host,
port,
username: hostFirst ? parts[2] : parts[0],
password: hostFirst ? parts[3] : parts[1],
original_line: trimmed,
};
}
/**
* Picks the proxy to use out of a parse of pasted text. Only the first usable
* line matters: the form holds one proxy, and a paste that happens to carry a
* whole list should still fill it in rather than do nothing.
*
* Ambiguous lines resolve as `host:port:username:password`, the ordering the
* import dialog offers first and the one vendors overwhelmingly ship.
*/
export function pickParsedProxy(
results: ProxyParseResult[],
): ParsedProxyLine | null {
for (const result of results) {
if (result.status === "parsed") {
return {
proxy_type: result.proxy_type,
host: result.host,
port: result.port,
username: result.username,
password: result.password,
vless_uri: result.vless_uri,
original_line: result.original_line,
};
}
if (result.status === "ambiguous") {
const resolved = resolveAmbiguousProxyLine(
result.line,
HOST_FIRST_FORMAT,
);
if (resolved) {
return resolved;
}
}
}
return null;
}
+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;
}
+60 -4
View File
@@ -62,6 +62,11 @@ export interface Extension {
description?: string;
author?: string;
homepage_url?: string;
/** How the payload was imported: a `.crx`/`.zip` archive, or a folder. */
source_kind: "archive" | "unpacked";
/** Absolute folder the extension is loaded from in place. Set means nothing
* was copied into Donut, so the extension is machine-local and never syncs. */
linked_path?: string;
}
export interface ExtensionGroup {
@@ -275,11 +280,44 @@ export interface DetectedProfile {
export interface ImportProfileItem {
source_path: string;
/**
* Source browser family. Selects which OS keychain entry holds the key that
* unlocks the source's cookies and passwords, so it decides whether secrets
* survive the import.
*/
browser_type?: string;
new_profile_name: string;
/** Mutually exclusive with `vpn_id`; the importer rejects setting both. */
proxy_id?: string | null;
vpn_id?: string | null;
/** Import even though the source browser is still running. */
allow_running?: boolean;
}
/** Stable warning codes; each maps to `importProfile.warnings.*`. */
export type ProfileImportWarning =
| "secretsNotMigrated"
| "appBoundEncrypted"
| "storeTooOld"
| "storeTooNew"
| "sourceBrowserRunning"
| "securePreferencesReset"
| "extensionsPartial"
| "storeUnreadable";
export interface ProfileImportReport {
cookies_migrated: number;
cookies_unrecoverable: number;
passwords_migrated: number;
passwords_unrecoverable: number;
payment_methods_migrated: number;
payment_methods_unrecoverable: number;
extensions_migrated: number;
history_entries: number;
bookmarks: number;
local_storage_origins: number;
bytes_copied: number;
warnings: ProfileImportWarning[];
}
export interface ProfileImportItemResult {
@@ -288,6 +326,8 @@ export interface ProfileImportItemResult {
status: "imported" | "skipped" | "failed";
profile_id: string | null;
error: string | null;
/** What actually came across. Present when status is "imported". */
report?: ProfileImportReport | null;
}
export interface ProfileImportBatchResult {
@@ -673,7 +713,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 +736,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;