Compare commits

...

17 Commits

Author SHA1 Message Date
zhom 60c7c72036 chore: versiom bump 2026-05-26 04:42:31 +04:00
zhom f81e8b6162 refactor: more robust camoufox proxy handling 2026-05-26 04:40:19 +04:00
github-actions[bot] e4ecd0d18a chore: update flake.nix for v0.24.3 [skip ci] (#383)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-25 00:02:17 +00:00
github-actions[bot] 8bc2dc3102 docs: update CHANGELOG.md and README.md for v0.24.3 [skip ci] (#382)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-25 00:01:55 +00:00
zhom 55de231a37 docs: readme 2026-05-25 03:38:01 +04:00
zhom aab403fd9b docs: update preview 2026-05-25 02:31:06 +04:00
zhom 667a4c99f0 chore: version bump 2026-05-25 02:20:40 +04:00
zhom 9236ad38c8 refactor: cleanup 2026-05-25 02:19:20 +04:00
zhom 6850f2c573 chore: linting 2026-05-23 14:35:55 +04:00
zhom 0add6c2aae chore: update pnpm 2026-05-23 14:22:45 +04:00
zhom f54c359d15 chore: make telegram releases ai-generated 2026-05-23 14:22:45 +04:00
zhom 69da467ce0 refactor: cleanup, korean translation 2026-05-23 14:22:45 +04:00
zhom 375530e358 chore: workflow cleanup 2026-05-23 14:22:45 +04:00
andy d664e5cde6 Merge pull request #377 from zhom/dependabot/cargo/src-tauri/rust-dependencies-fa7ae92db0
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 25 updates
2026-05-23 03:22:35 -07:00
andy 096e4aaf4a Merge pull request #376 from zhom/dependabot/github_actions/github-actions-39fff3f52e
ci(deps): bump the github-actions group with 6 updates
2026-05-23 03:07:33 -07:00
dependabot[bot] 8305c45cb5 deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 25 updates:

| Package | From | To |
| --- | --- | --- |
| [serde_json](https://github.com/serde-rs/json) | `1.0.149` | `1.0.150` |
| [tauri](https://github.com/tauri-apps/tauri) | `2.11.1` | `2.11.2` |
| [sysinfo](https://github.com/GuillaumeGomez/sysinfo) | `0.39.1` | `0.39.2` |
| [tar](https://github.com/composefs/tar-rs) | `0.4.45` | `0.4.46` |
| [tower-http](https://github.com/tower-rs/tower-http) | `0.6.10` | `0.6.11` |
| [cbc](https://github.com/RustCrypto/block-modes) | `0.2.0` | `0.2.1` |
| [tao](https://github.com/tauri-apps/tao) | `0.35.2` | `0.35.3` |
| [tauri-build](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |
| [autocfg](https://github.com/cuviper/autocfg) | `1.5.0` | `1.5.1` |
| [built](https://github.com/lukaslueg/built) | `0.8.0` | `0.8.1` |
| [bumpalo](https://github.com/fitzgen/bumpalo) | `3.20.2` | `3.20.3` |
| [either](https://github.com/rayon-rs/either) | `1.15.0` | `1.16.0` |
| [libbz2-rs-sys](https://github.com/trifectatechfoundation/libbzip2-rs) | `0.2.4` | `0.2.5` |
| [muda](https://github.com/tauri-apps/muda) | `0.19.1` | `0.19.2` |
| [num-conv](https://github.com/jhpratt/num-conv) | `0.2.1` | `0.2.2` |
| [openssl](https://github.com/rust-openssl/rust-openssl) | `0.10.79` | `0.10.80` |
| [openssl-sys](https://github.com/rust-openssl/rust-openssl) | `0.9.115` | `0.9.116` |
| rsqlite-vfs | `0.1.0` | `0.1.1` |
| [sqlite-wasm-rs](https://github.com/Spxg/sqlite-wasm-rs) | `0.5.3` | `0.5.4` |
| [tauri-codegen](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |
| [tauri-macros](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |
| [tauri-plugin](https://github.com/tauri-apps/tauri) | `2.6.1` | `2.6.2` |
| [tauri-runtime](https://github.com/tauri-apps/tauri) | `2.11.1` | `2.11.2` |
| [tauri-runtime-wry](https://github.com/tauri-apps/tauri) | `2.11.1` | `2.11.2` |
| [tauri-utils](https://github.com/tauri-apps/tauri) | `2.9.1` | `2.9.2` |


Updates `serde_json` from 1.0.149 to 1.0.150
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.149...v1.0.150)

Updates `tauri` from 2.11.1 to 2.11.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.11.1...tauri-v2.11.2)

Updates `sysinfo` from 0.39.1 to 0.39.2
- [Changelog](https://github.com/GuillaumeGomez/sysinfo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/GuillaumeGomez/sysinfo/compare/v0.39.1...v0.39.2)

Updates `tar` from 0.4.45 to 0.4.46
- [Release notes](https://github.com/composefs/tar-rs/releases)
- [Commits](https://github.com/composefs/tar-rs/compare/0.4.45...0.4.46)

Updates `tower-http` from 0.6.10 to 0.6.11
- [Release notes](https://github.com/tower-rs/tower-http/releases)
- [Commits](https://github.com/tower-rs/tower-http/compare/tower-http-0.6.10...tower-http-0.6.11)

Updates `cbc` from 0.2.0 to 0.2.1
- [Commits](https://github.com/RustCrypto/block-modes/compare/cbc-v0.2.0...cbc-v0.2.1)

Updates `tao` from 0.35.2 to 0.35.3
- [Release notes](https://github.com/tauri-apps/tao/releases)
- [Changelog](https://github.com/tauri-apps/tao/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/tao/compare/tao-v0.35.2...tao-v0.35.3)

Updates `tauri-build` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-build-v2.6.1...tauri-build-v2.6.2)

Updates `autocfg` from 1.5.0 to 1.5.1
- [Commits](https://github.com/cuviper/autocfg/compare/1.5.0...1.5.1)

Updates `built` from 0.8.0 to 0.8.1
- [Changelog](https://github.com/lukaslueg/built/blob/master/CHANGELOG.md)
- [Commits](https://github.com/lukaslueg/built/compare/0.8.0...0.8.1)

Updates `bumpalo` from 3.20.2 to 3.20.3
- [Changelog](https://github.com/fitzgen/bumpalo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/fitzgen/bumpalo/compare/v3.20.2...v3.20.3)

Updates `either` from 1.15.0 to 1.16.0
- [Commits](https://github.com/rayon-rs/either/compare/1.15.0...1.16.0)

Updates `libbz2-rs-sys` from 0.2.4 to 0.2.5
- [Release notes](https://github.com/trifectatechfoundation/libbzip2-rs/releases)
- [Changelog](https://github.com/trifectatechfoundation/libbzip2-rs/blob/main/NEWS.md)
- [Commits](https://github.com/trifectatechfoundation/libbzip2-rs/compare/0.2.4...v0.2.5)

Updates `muda` from 0.19.1 to 0.19.2
- [Release notes](https://github.com/tauri-apps/muda/releases)
- [Changelog](https://github.com/tauri-apps/muda/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/muda/compare/muda-v0.19.1...muda-v0.19.2)

Updates `num-conv` from 0.2.1 to 0.2.2
- [Commits](https://github.com/jhpratt/num-conv/compare/v0.2.1...v0.2.2)

Updates `openssl` from 0.10.79 to 0.10.80
- [Release notes](https://github.com/rust-openssl/rust-openssl/releases)
- [Commits](https://github.com/rust-openssl/rust-openssl/compare/openssl-v0.10.79...openssl-v0.10.80)

Updates `openssl-sys` from 0.9.115 to 0.9.116
- [Release notes](https://github.com/rust-openssl/rust-openssl/releases)
- [Commits](https://github.com/rust-openssl/rust-openssl/compare/openssl-sys-v0.9.115...openssl-sys-v0.9.116)

Updates `rsqlite-vfs` from 0.1.0 to 0.1.1

Updates `sqlite-wasm-rs` from 0.5.3 to 0.5.4
- [Release notes](https://github.com/Spxg/sqlite-wasm-rs/releases)
- [Changelog](https://github.com/Spxg/sqlite-wasm-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Spxg/sqlite-wasm-rs/compare/0.5.3...0.5.4)

Updates `tauri-codegen` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-codegen-v2.6.1...tauri-codegen-v2.6.2)

Updates `tauri-macros` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-macros-v2.6.1...tauri-macros-v2.6.2)

Updates `tauri-plugin` from 2.6.1 to 2.6.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-plugin-v2.6.1...tauri-plugin-v2.6.2)

Updates `tauri-runtime` from 2.11.1 to 2.11.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-runtime-v2.11.1...tauri-runtime-v2.11.2)

Updates `tauri-runtime-wry` from 2.11.1 to 2.11.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-runtime-wry-v2.11.1...tauri-runtime-wry-v2.11.2)

Updates `tauri-utils` from 2.9.1 to 2.9.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-utils-v2.9.1...tauri-utils-v2.9.2)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.150
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri
  dependency-version: 2.11.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: sysinfo
  dependency-version: 0.39.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tar
  dependency-version: 0.4.46
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tower-http
  dependency-version: 0.6.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: cbc
  dependency-version: 0.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tao
  dependency-version: 0.35.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-build
  dependency-version: 2.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: autocfg
  dependency-version: 1.5.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: built
  dependency-version: 0.8.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: bumpalo
  dependency-version: 3.20.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: either
  dependency-version: 1.16.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: libbz2-rs-sys
  dependency-version: 0.2.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: muda
  dependency-version: 0.19.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: num-conv
  dependency-version: 0.2.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: openssl
  dependency-version: 0.10.80
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: openssl-sys
  dependency-version: 0.9.116
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: rsqlite-vfs
  dependency-version: 0.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: sqlite-wasm-rs
  dependency-version: 0.5.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-codegen
  dependency-version: 2.6.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-macros
  dependency-version: 2.6.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin
  dependency-version: 2.6.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-runtime
  dependency-version: 2.11.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-runtime-wry
  dependency-version: 2.11.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-utils
  dependency-version: 2.9.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 09:44:23 +00:00
dependabot[bot] ff3634e6cc ci(deps): bump the github-actions group with 6 updates
Bumps the github-actions group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/github-script](https://github.com/actions/github-script) | `7.1.0` | `9.0.0` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.0.0` | `4.1.0` |
| [docker/login-action](https://github.com/docker/login-action) | `4.1.0` | `4.2.0` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `7.1.0` | `7.2.0` |
| [anomalyco/opencode](https://github.com/anomalyco/opencode) | `1.15.3` | `1.15.10` |
| [actions/stale](https://github.com/actions/stale) | `10.2.0` | `10.3.0` |


Updates `actions/github-script` from 7.1.0 to 9.0.0
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/f28e40c7f34bde8b3046d885e986cb6290c5673b...3a2844b7e9c422d3c10d287c895573f7108da1b3)

Updates `docker/setup-buildx-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd...d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5)

Updates `docker/login-action` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/4907a6ddec9925e35a0a9e82d7399ccc52663121...650006c6eb7dba73a995cc03b0b2d7f5ca915bee)

Updates `docker/build-push-action` from 7.1.0 to 7.2.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/bcafcacb16a39f128d818304e6c9c0c18556b85f...f9f3042f7e2789586610d6e8b85c8f03e5195baf)

Updates `anomalyco/opencode` from 1.15.3 to 1.15.10
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/37f89b742907c43b20d38b68eabe65981a59690a...d74d166acf40e51146f8547216913a4e787a4bc1)

Updates `actions/stale` from 10.2.0 to 10.3.0
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/b5d41d4e1d5dceea10e7104786b73624c18a190f...eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: 9.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: docker/login-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: anomalyco/opencode
  dependency-version: 1.15.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: actions/stale
  dependency-version: 10.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 09:05:26 +00:00
53 changed files with 3234 additions and 618 deletions
@@ -0,0 +1,23 @@
messages:
- role: system
content: |-
You write short, friendly release summaries for Donut Browser, an anti-detect browser desktop app built with Tauri and Next.js.
Rules:
- Keep it minimal and friendly. No marketing voice, no filler, no superlatives.
- No emojis or pictographic symbols.
- Plain ASCII punctuation only. No em-dashes, en-dashes, ellipses, smart quotes, or any non-ASCII characters. Use a regular hyphen, three dots, or straight quotes instead.
- Plain text only. No markdown (no asterisks for bold, no backticks for code, no headings), no HTML tags.
- Focus on user-visible changes. Skip chore, docs-only, CI, test, dependency, formatting, and purely internal refactor commits unless they have user-visible impact.
- Group related commits into a single bullet when it reads better.
- Use simple, direct language.
- Do not include the version number, download links, or a heading. The surrounding message already has those.
- If nothing in the commits is user-visible, output exactly one bullet: "- Small fixes and internal improvements."
- role: user
content: |-
Write the summary for Donut Browser {{version}} from these commits:
{{commits}}
Format: one short opening sentence, a blank line, then bullets starting with "- " (one per line). Nothing else.
model: openai/gpt-4.1
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Close non-compliant issues and PRs after 24 hours
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { data: items } = await github.rest.issues.listForRepo({
+3 -3
View File
@@ -33,10 +33,10 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 #v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee #v4.2.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -62,7 +62,7 @@ jobs:
echo "Tags: ${TAGS}"
- name: Build and push Docker image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f #v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf #v7.2.0
with:
context: .
file: ./donut-sync/Dockerfile
@@ -1,4 +1,4 @@
name: Duplicate Issue Check
name: Issue Compliance Check
on:
issues:
@@ -12,7 +12,7 @@ env:
MODEL: z-ai/glm-5.1
jobs:
check-duplicates:
check-compliance:
if: github.repository == 'zhom/donutbrowser' && github.event.action == 'opened'
runs-on: ubuntu-latest
steps:
@@ -21,29 +21,16 @@ jobs:
- name: Gather context
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
# Pull up to 150 open/closed issues for the LLM to compare against.
# Exclude the issue under inspection and any PRs (gh issue list does
# this naturally).
gh issue list \
--repo "$GITHUB_REPOSITORY" \
--state all \
--limit 150 \
--json number,title,state,body \
--jq "[.[] | select(.number != $ISSUE_NUMBER) | {number, title, state, body: (.body[:400] // \"\")}]" \
> /tmp/existing-issues.json
- name: Build prompt
run: |
cat > /tmp/system.txt <<'PROMPT'
You are reviewing a new GitHub issue for two things — template compliance and possible duplicates. Return ONLY a single JSON object, no prose, no markdown fences.
You are reviewing a new GitHub issue for template compliance. Return ONLY a single JSON object, no prose, no markdown fences.
Project: Donut Browser. There are three valid templates:
- Bug Report (Description + Operating System + Donut Browser version + Which browser is affected + Steps to reproduce + Error logs/screenshots fields)
@@ -59,22 +46,14 @@ jobs:
Do NOT flag for missing optional fields, missing screenshots, short titles, or stylistic issues. Be conservative.
## Duplicates — flag candidates ONLY when at least one of these is true
- Same error message, exception, or symptom
- Same feature being requested
- Same root cause area (e.g. "proxy disconnects on Camoufox/Windows")
Prefer false negatives over false positives. Two issues about Wayfern are not duplicates if they are about different features.
## Output schema
{
"is_compliant": true | false,
"non_compliance_reasons": ["short bullet", ...],
"duplicates": [{"number": 123, "reason": "short reason"}]
"non_compliance_reasons": ["short bullet", ...]
}
Empty arrays are fine. If there is nothing to flag, return:
{"is_compliant": true, "non_compliance_reasons": [], "duplicates": []}
If there is nothing to flag, return:
{"is_compliant": true, "non_compliance_reasons": []}
PROMPT
- name: Call OpenRouter
@@ -86,13 +65,12 @@ jobs:
--rawfile system_prompt /tmp/system.txt \
--rawfile title /tmp/issue-title.txt \
--rawfile body /tmp/issue-body.txt \
--rawfile existing /tmp/existing-issues.json \
'{
model: $model,
messages: [
{ role: "system", content: $system_prompt },
{ role: "user",
content: ("New issue title: " + $title + "\n\nNew issue body:\n" + $body + "\n\nExisting issues (JSON array):\n" + $existing) }
content: ("New issue title: " + $title + "\n\nNew issue body:\n" + $body) }
],
response_format: { type: "json_object" }
}')
@@ -108,9 +86,9 @@ jobs:
# to a noop result so the workflow doesn't fail the issue author's run.
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 no-op"
echo "::warning::Model returned non-JSON; treating as compliant"
cat /tmp/raw.txt
echo '{"is_compliant": true, "non_compliance_reasons": [], "duplicates": []}' > /tmp/result.json
echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json
fi
echo "Result:"
cat /tmp/result.json
@@ -122,7 +100,6 @@ jobs:
r = json.load(open('/tmp/result.json'))
compliant = bool(r.get('is_compliant', True))
reasons = r.get('non_compliance_reasons') or []
dups = r.get('duplicates') or []
parts = []
if not compliant:
@@ -134,25 +111,11 @@ jobs:
parts.append(f'- {reason}')
parts.append('')
parts.append('Please edit this issue to address the above within **24 hours**, or it will be automatically closed.')
if dups:
if parts:
parts.append('')
parts.append('---')
parts.append('This issue might duplicate existing reports. Please check:')
for d in dups:
num = d.get('number')
reason = d.get('reason', '').strip()
if num:
parts.append(f'- #{num}{" — " + reason if reason else ""}')
if not compliant:
parts.append('')
parts.append('If you believe this was flagged incorrectly, please let a maintainer know.')
comment = '\n'.join(parts).strip()
open('/tmp/comment.md', 'w').write(comment)
# Expose flags for downstream steps via GITHUB_OUTPUT-style write.
with open(os.environ['GITHUB_OUTPUT'], 'a') as fh:
fh.write(f'has_comment={"true" if comment else "false"}\n')
fh.write(f'non_compliant={"true" if not compliant else "false"}\n')
+12 -7
View File
@@ -102,12 +102,14 @@ jobs:
its API, MCP server, and the bundled `donut-sync` self-hosted server.
- **Wayfern** — a Chromium fork maintained by zhom (the same maintainer). Wayfern
bugs are in-scope here unless they are obviously upstream Chromium issues.
- **Camoufox** — a Firefox fork by daijro. The maintainer of THIS repo does NOT
contribute to Camoufox and CANNOT fix bugs in it.
- **Camoufox** — a Firefox fork by daijro, used by Donut but maintained in a
separate repository. Bugs about Camoufox's *internal* behavior are outside
the scope of this project.
- Bugs about Camoufox's *internal* behavior (page rendering, JS engine,
dropdowns, form widgets, fingerprinting *as Camoufox implements it*,
checkbox/radio quirks) are UPSTREAM ONLY. Redirect to
https://github.com/daijro/camoufox/issues.
checkbox/radio quirks) are out of scope here. Ask the user to first
search https://github.com/daijro/camoufox/issues for a matching report,
and if they don't find one, to open it there themselves.
- Bugs about how Donut *launches, configures, or downloads* Camoufox are
in-scope here.
- **Forks of Wayfern or Camoufox** (e.g. CloverLabsAI, VulpineOS) are NOT
@@ -146,7 +148,10 @@ jobs:
dismiss as "known issue" / "expected" / "false positive in Tauri apps". Ask
which exact version was the last working one and what changed.
- **Out-of-scope (upstream Camoufox)**: report is about Camoufox's own
behavior. Redirect, do not collect logs.
behavior. Tell the user it's outside the scope of this project and ask
them to search the Camoufox repo and, if no matching issue exists, file
one there. Do NOT say the maintainer doesn't contribute / can't fix it
— keep it strictly about project scope. Do not collect logs.
- **Fork-support request**: asks the maintainer to support an alternative
Wayfern/Camoufox fork. Acknowledge in one neutral sentence — do NOT call it
"clear", "reasonable", "well-thought-out", etc.
@@ -342,7 +347,7 @@ jobs:
The triage classification (`triage.classification`) determines the response shape:
- `bug-in-scope`: ask for what is missing using the user's reported OS log path. Be concrete about how to obtain logs.
- `bug-upstream-camoufox`: redirect ONLY. One sentence acknowledging, then a sentence saying this is a Camoufox-internal issue and the maintainer of this repo does not contribute to Camoufox; ask the user to file at https://github.com/daijro/camoufox/issues. Do NOT ask for Donut logs. Stop after that.
- `bug-upstream-camoufox`: redirect ONLY. One sentence acknowledging, then say this is outside the scope of this project — ask the user to first search https://github.com/daijro/camoufox/issues for a matching report and, if none exists, to open one there themselves. Do NOT phrase it as "the maintainer does not contribute" or anything personal — keep it strictly about scope. Do NOT ask for Donut logs. Stop after that.
- `bug-template-violation` or `ai-generated-junk`: politely ask the user to refile using the bug-report template (the Operating System, Donut Browser version, Which browser, Steps to reproduce, Error logs sections). If they cited "documentation" from any non-`donutbrowser.com`/non-`github.com/zhom` URL (e.g. context7, deepwiki), gently note that those are AI-generated third-party summaries and the only authoritative sources are this repo and donutbrowser.com.
- `feature-request`: one neutral sentence acknowledging, then ask only what is genuinely needed (concrete use case, whether a workaround would suffice). Do NOT validate.
- `fork-request`: one neutral sentence acknowledging the request. Note that this would substantially increase support burden and the maintainer evaluates such requests on a case-by-case basis. Ask whether the alternative fork supports all platforms the user uses (macOS / Windows / Linux). No "clear enhancement" language.
@@ -615,7 +620,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Run opencode
uses: anomalyco/opencode/github@37f89b742907c43b20d38b68eabe65981a59690a #v1.15.3
uses: anomalyco/opencode/github@d74d166acf40e51146f8547216913a4e787a4bc1 #v1.15.10
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+45 -30
View File
@@ -22,6 +22,7 @@ on:
permissions:
contents: read
models: read
jobs:
notify:
@@ -105,21 +106,12 @@ jobs:
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Post release announcement to Telegram
- name: Collect commits between previous tag and current tag
id: commits
if: steps.gate.outputs.skip != 'true'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
TAG: ${{ steps.tag.outputs.tag }}
REPO: ${{ github.repository }}
run: |
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "::warning::TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set — skipping Telegram notification."
exit 0
fi
# Find the previous stable tag (skip the current one) so the
# changelog range is well-defined.
PREV_TAG=$(git tag --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -v "^${TAG}$" \
@@ -127,29 +119,52 @@ jobs:
if [ -z "$PREV_TAG" ]; then
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
fi
git log --pretty=format:"- %s (%h)" "${PREV_TAG}..${TAG}" --no-merges > commits.txt
echo "previous-tag=${PREV_TAG}" >> "$GITHUB_OUTPUT"
echo "Collected $(wc -l < commits.txt) commits between ${PREV_TAG} and ${TAG}."
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
- name: Generate summary with AI
id: ai
if: steps.gate.outputs.skip != 'true'
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
with:
prompt-file: .github/prompts/telegram-release-summary.prompt.yml
input: |
version: ${{ steps.tag.outputs.tag }}
file_input: |
commits: ./commits.txt
max-tokens: 1024
# Build a plain bullet list from feat / fix / refactor commits.
# Other commit types (chore, docs, ci, test, deps) are intentionally
# filtered out to keep the channel focused on user-visible changes.
CHANGES=""
while IFS= read -r msg; do
[ -z "$msg" ] && continue
case "$msg" in
feat\(*\):*|feat:*|fix\(*\):*|fix:*|refactor\(*\):*|refactor:*)
CHANGES="${CHANGES}• $(strip_prefix "$msg")"$'\n'
;;
esac
done < <(git log --pretty=format:%s "${PREV_TAG}..${TAG}")
if [ -z "$CHANGES" ]; then
CHANGES="• See release notes."$'\n'
- name: Post release announcement to Telegram
if: steps.gate.outputs.skip != 'true'
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
TAG: ${{ steps.tag.outputs.tag }}
REPO: ${{ github.repository }}
AI_RESPONSE_FILE: ${{ steps.ai.outputs.response-file }}
AI_RESPONSE: ${{ steps.ai.outputs.response }}
run: |
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "::warning::TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set — skipping Telegram notification."
exit 0
fi
# HTML-escape the changelog before injecting into Telegram HTML
# mode — commit messages can legitimately contain `<`, `>`, `&`.
ESCAPED_CHANGES=$(printf '%s' "$CHANGES" \
# Prefer the file output — `response` can be truncated for longer summaries.
if [ -n "$AI_RESPONSE_FILE" ] && [ -f "$AI_RESPONSE_FILE" ]; then
SUMMARY=$(cat "$AI_RESPONSE_FILE")
else
SUMMARY="$AI_RESPONSE"
fi
if [ -z "${SUMMARY//[[:space:]]/}" ]; then
echo "::error::AI summary is empty"
exit 1
fi
# HTML-escape the AI summary before injecting into Telegram HTML mode —
# commit messages can legitimately contain `<`, `>`, `&` and the AI may echo them.
ESCAPED_CHANGES=$(printf '%s' "$SUMMARY" \
| python3 -c "import html, sys; sys.stdout.write(html.escape(sys.stdin.read()))")
VERSION="${TAG}"
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open."
+35
View File
@@ -1,6 +1,41 @@
# Changelog
## v0.24.3 (2026-05-25)
### Features
- add shortcuts
### Bug Fixes
- track gecko_id for extension groups
### Refactoring
- cleanup
- cleanup, korean translation
- reduce token usage
### Maintenance
- chore: version bump
- chore: linting
- chore: update pnpm
- chore: make telegram releases ai-generated
- chore: workflow cleanup
- ci(deps): bump the github-actions group with 6 updates
- chore: use less tokens
- chore: improve issue validation
- ci(deps): bump the github-actions group across 1 directory with 6 updates
- chore: update flake.nix for v0.24.2 [skip ci] (#370)
### Other
- deps(rust)(deps): bump the rust-dependencies group
- deps(rust)(deps): bump the rust-dependencies group
## v0.24.2 (2026-05-16)
### Features
+6 -8
View File
@@ -19,9 +19,6 @@
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/network/members" target="_blank">
<img src="https://img.shields.io/github/forks/zhom/donutbrowser?style=social" alt="GitHub forks">
</a>
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/releases" target="_blank">
<img src="https://img.shields.io/github/downloads/zhom/donutbrowser/total" alt="Downloads">
</a>
</p>
<img alt="Donut Browser Preview" src="assets/donut-preview.png" />
@@ -30,6 +27,7 @@
- **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data
- **Chromium & Firefox engines** — Chromium powered by [Wayfern](https://wayfern.com), Firefox powered by [Camoufox](https://camoufox.com), both with advanced fingerprint spoofing
- **DNS AdBlocker** - block ads, trackers, and other unwanted content with per-profile DNS blocking
- **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
- **VPN support** — WireGuard configs per profile
- **Local API & MCP** — REST API and [Model Context Protocol](https://modelcontextprotocol.io) server for integration with Claude, automation tools, and custom workflows
@@ -48,7 +46,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_x64.dmg) |
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut_0.24.3_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut_0.24.3_x64.dmg) |
Or install via Homebrew:
@@ -58,15 +56,15 @@ brew install --cask donut
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_x64-portable.zip)
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut_0.24.3_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut_0.24.3_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut-0.24.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut-0.24.2-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_aarch64.AppImage) |
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut_0.24.3_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut_0.24.3_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut-0.24.3-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut-0.24.3-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut_0.24.3_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut_0.24.3_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 623 KiB

After

Width:  |  Height:  |  Size: 508 KiB

+5 -5
View File
@@ -94,17 +94,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.24.2";
releaseVersion = "0.24.3";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_amd64.AppImage";
hash = "sha256-140PSB/1BLGUB4sI/RgfYe7uUjwRFWXtdSnUZz6Wr0U=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut_0.24.3_amd64.AppImage";
hash = "sha256-4RXEpNiD10hhZhBJ96lhvRG+K6ZrsEF+atwfkAicnhc=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_aarch64.AppImage";
hash = "sha256-QPGV6XO0ugPAJSbPJrVwDsEb9lw3dcL6IdU17UCYH4E=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut_0.24.3_aarch64.AppImage";
hash = "sha256-EmyJwfUnEQ3vtS2N99QrGrsNESHmiqIdGCrTYvTlMTI=";
}
else
null;
+2 -12
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.24.2",
"version": "0.24.4",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
@@ -89,17 +89,7 @@
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.3"
},
"pnpm": {
"overrides": {
"picomatch@>=4.0.0 <4.0.4": ">=4.0.4",
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0",
"postcss@<8.5.10": ">=8.5.12",
"fast-xml-parser@<5.7.0": ">=5.7.2",
"fast-uri@<3.1.2": ">=3.1.2",
"fast-xml-builder@<1.2.0": ">=1.2.0"
}
},
"packageManager": "pnpm@10.33.2",
"packageManager": "pnpm@11.2.2",
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css}": [
"biome check --fix"
+27 -26
View File
@@ -11,6 +11,8 @@ overrides:
fast-xml-parser@<5.7.0: '>=5.7.2'
fast-uri@<3.1.2: '>=3.1.2'
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'
importers:
@@ -212,7 +214,7 @@ importers:
devDependencies:
'@nestjs/cli':
specifier: ^11.0.21
version: 11.0.21(@types/node@25.7.0)(lightningcss@1.32.0)
version: 11.0.21(@types/node@25.7.0)
'@nestjs/schematics':
specifier: ^11.1.0
version: 11.1.0(chokidar@4.0.3)(typescript@6.0.3)
@@ -248,7 +250,7 @@ importers:
version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.4.2(@types/node@25.7.0)(ts-node@10.9.2(@types/node@25.7.0)(typescript@6.0.3)))(typescript@6.0.3)
ts-loader:
specifier: ^9.5.7
version: 9.5.7(typescript@6.0.3)(webpack@5.106.0(lightningcss@1.32.0))
version: 9.5.7(typescript@6.0.3)(webpack@5.106.0)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@25.7.0)(typescript@6.0.3)
@@ -2060,6 +2062,7 @@ packages:
'@smithy/core@3.24.1':
resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==}
engines: {node: '>=18.0.0'}
deprecated: Deprecated due to bug in browser bundling instructions https://github.com/smithy-lang/smithy-typescript/issues/2025
'@smithy/credential-provider-imds@4.3.1':
resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==}
@@ -3872,9 +3875,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
js-cookie@3.0.5:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'}
js-cookie@3.0.7:
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
engines: {node: '>=20'}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -4401,8 +4404,8 @@ packages:
pure-rand@7.0.1:
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
qs@6.15.1:
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
radix-ui@1.4.3:
@@ -6421,7 +6424,7 @@ snapshots:
'@tybys/wasm-util': 0.10.2
optional: true
'@nestjs/cli@11.0.21(@types/node@25.7.0)(lightningcss@1.32.0)':
'@nestjs/cli@11.0.21(@types/node@25.7.0)':
dependencies:
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
@@ -6432,14 +6435,14 @@ snapshots:
chokidar: 4.0.3
cli-table3: 0.6.5
commander: 4.1.1
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(lightningcss@1.32.0))
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0)
glob: 13.0.6
node-emoji: 1.11.0
ora: 5.4.1
tsconfig-paths: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
typescript: 5.9.3
webpack: 5.106.0(lightningcss@1.32.0)
webpack: 5.106.0
webpack-node-externals: 3.0.0
transitivePeerDependencies:
- '@minify-html/node'
@@ -8125,7 +8128,7 @@ snapshots:
'@types/js-cookie': 3.0.6
dayjs: 1.11.20
intersection-observer: 0.12.2
js-cookie: 3.0.5
js-cookie: 3.0.7
lodash: 4.18.1
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
@@ -8295,7 +8298,7 @@ snapshots:
http-errors: 2.0.1
iconv-lite: 0.7.2
on-finished: 2.4.1
qs: 6.15.1
qs: 6.15.2
raw-body: 3.0.2
type-is: 2.0.1
transitivePeerDependencies:
@@ -8733,7 +8736,7 @@ snapshots:
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
qs: 6.15.1
qs: 6.15.2
range-parser: 1.2.1
router: 2.2.0
send: 1.2.1
@@ -8804,7 +8807,7 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(lightningcss@1.32.0)):
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0):
dependencies:
'@babel/code-frame': 7.29.0
chalk: 4.1.2
@@ -8819,7 +8822,7 @@ snapshots:
semver: 7.8.0
tapable: 2.3.3
typescript: 5.9.3
webpack: 5.106.0(lightningcss@1.32.0)
webpack: 5.106.0
form-data@4.0.5:
dependencies:
@@ -9382,7 +9385,7 @@ snapshots:
jiti@2.7.0: {}
js-cookie@3.0.5: {}
js-cookie@3.0.7: {}
js-tokens@4.0.0: {}
@@ -9834,7 +9837,7 @@ snapshots:
pure-rand@7.0.1: {}
qs@6.15.1:
qs@6.15.2:
dependencies:
side-channel: 1.1.0
@@ -10294,7 +10297,7 @@ snapshots:
formidable: 3.5.4
methods: 1.1.2
mime: 2.6.0
qs: 6.15.1
qs: 6.15.2
transitivePeerDependencies:
- supports-color
@@ -10330,15 +10333,13 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.11.0
terser-webpack-plugin@5.6.0(lightningcss@1.32.0)(webpack@5.106.0(lightningcss@1.32.0)):
terser-webpack-plugin@5.6.0(webpack@5.106.0):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
terser: 5.47.1
webpack: 5.106.0(lightningcss@1.32.0)
optionalDependencies:
lightningcss: 1.32.0
webpack: 5.106.0
terser@5.47.1:
dependencies:
@@ -10391,7 +10392,7 @@ snapshots:
babel-jest: 30.4.1(@babel/core@7.29.0)
jest-util: 30.4.1
ts-loader@9.5.7(typescript@6.0.3)(webpack@5.106.0(lightningcss@1.32.0)):
ts-loader@9.5.7(typescript@6.0.3)(webpack@5.106.0):
dependencies:
chalk: 4.1.2
enhanced-resolve: 5.21.3
@@ -10399,7 +10400,7 @@ snapshots:
semver: 7.8.0
source-map: 0.7.6
typescript: 6.0.3
webpack: 5.106.0(lightningcss@1.32.0)
webpack: 5.106.0
ts-node@10.9.2(@types/node@25.7.0)(typescript@6.0.3):
dependencies:
@@ -10588,7 +10589,7 @@ snapshots:
webpack-sources@3.4.1: {}
webpack@5.106.0(lightningcss@1.32.0):
webpack@5.106.0:
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.9
@@ -10612,7 +10613,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.3
terser-webpack-plugin: 5.6.0(lightningcss@1.32.0)(webpack@5.106.0(lightningcss@1.32.0))
terser-webpack-plugin: 5.6.0(webpack@5.106.0)
watchpack: 2.5.1
webpack-sources: 3.4.1
transitivePeerDependencies:
+22
View File
@@ -11,3 +11,25 @@ onlyBuiltDependencies:
- sharp
- sqlite3
- unrs-resolver
# Husky and lint-staged shell out to pnpm without a TTY, so the interactive
# "purge modules dir?" prompt errors out (ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY)
# and aborts the commit. Skipping the prompt lets the hook proceed.
confirmModulesPurge: false
# Pinned for security. Moved from package.json#pnpm.overrides — pnpm 11
# no longer reads that field; settings live here now.
overrides:
picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
postcss@<8.5.10: '>=8.5.12'
fast-xml-parser@<5.7.0: '>=5.7.2'
fast-uri@<3.1.2: '>=3.1.2'
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'
allowBuilds:
'@nestjs/core': true
sharp: true
unrs-resolver: true
+75 -90
View File
@@ -35,7 +35,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8"
dependencies = [
"cipher 0.5.1",
"cipher 0.5.2",
"cpubits",
"cpufeatures 0.3.0",
]
@@ -169,7 +169,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -180,7 +180,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -445,9 +445,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "av-scenechange"
@@ -785,15 +785,15 @@ dependencies = [
[[package]]
name = "built"
version = "0.8.0"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64"
checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9"
[[package]]
name = "bumpalo"
version = "3.20.2"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "byte-unit"
@@ -871,15 +871,6 @@ dependencies = [
"bzip2-sys",
]
[[package]]
name = "bzip2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
dependencies = [
"libbz2-rs-sys",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
@@ -971,11 +962,11 @@ dependencies = [
[[package]]
name = "cbc"
version = "0.2.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98db6aeaef0eeef2c1e3ce9a27b739218825dae116076352ac3777076aa22225"
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
dependencies = [
"cipher 0.5.1",
"cipher 0.5.2",
]
[[package]]
@@ -1112,11 +1103,11 @@ dependencies = [
[[package]]
name = "cipher"
version = "0.5.1"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common 0.2.1",
"crypto-common 0.2.2",
"inout 0.2.2",
]
@@ -1414,9 +1405,9 @@ dependencies = [
[[package]]
name = "crypto-common"
version = "0.2.1"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
@@ -1688,7 +1679,7 @@ checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.0",
"const-oid 0.10.2",
"crypto-common 0.2.1",
"crypto-common 0.2.2",
]
[[package]]
@@ -1718,7 +1709,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -1793,7 +1784,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.24.2"
version = "0.24.4"
dependencies = [
"aes 0.9.0",
"aes-gcm",
@@ -1804,7 +1795,7 @@ dependencies = [
"base64 0.22.1",
"blake3",
"boringtun",
"bzip2 0.6.1",
"bzip2",
"cbc",
"chrono",
"chrono-tz",
@@ -1971,9 +1962,9 @@ dependencies = [
[[package]]
name = "either"
version = "1.15.0"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "embed-resource"
@@ -2108,7 +2099,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3096,7 +3087,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.2",
"windows-core 0.61.2",
]
[[package]]
@@ -3615,12 +3606,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "libbz2-rs-sys"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fc329e1457d97a9d58a4e2ca49e3be572431a7e096008efc2e3a3c19d428f4"
[[package]]
name = "libc"
version = "0.2.186"
@@ -3930,9 +3915,9 @@ dependencies = [
[[package]]
name = "muda"
version = "0.19.1"
version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb"
checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c"
dependencies = [
"crossbeam-channel",
"dpi",
@@ -3947,7 +3932,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -4054,9 +4039,9 @@ dependencies = [
[[package]]
name = "num-conv"
version = "0.2.1"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-derive"
@@ -4410,9 +4395,9 @@ dependencies = [
[[package]]
name = "openssl"
version = "0.10.79"
version = "0.10.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542"
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
@@ -4441,9 +4426,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.115"
version = "0.9.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781"
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
dependencies = [
"cc",
"libc",
@@ -4484,7 +4469,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.45.0",
]
[[package]]
@@ -5523,9 +5508,9 @@ dependencies = [
[[package]]
name = "rsqlite-vfs"
version = "0.1.0"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.18",
@@ -5598,7 +5583,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5888,9 +5873,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.149"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
@@ -6267,7 +6252,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6339,9 +6324,9 @@ dependencies = [
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36"
checksum = "cdd578e94101503d97e2b286bbf8db2135035ca24b2ce4cbf3f9e2fb2bbf1eee"
dependencies = [
"cc",
"js-sys",
@@ -6483,9 +6468,9 @@ dependencies = [
[[package]]
name = "sysinfo"
version = "0.39.1"
version = "0.39.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4deba334e1190ba7cb498327affa11e5ece10d26a30ab2f27fcf09504b8d8b6"
checksum = "14311e7e9a03114cd4b65eedd54e8fed2945e17f08586ae97ef53bc0669f9581"
dependencies = [
"libc",
"memchr",
@@ -6532,9 +6517,9 @@ dependencies = [
[[package]]
name = "tao"
version = "0.35.2"
version = "0.35.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4"
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.11.1",
"block2",
@@ -6589,9 +6574,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.45"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
@@ -6606,9 +6591,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.11.1"
version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405"
checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28"
dependencies = [
"anyhow",
"bytes",
@@ -6657,9 +6642,9 @@ dependencies = [
[[package]]
name = "tauri-build"
version = "2.6.1"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007"
checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7"
dependencies = [
"anyhow",
"cargo_toml",
@@ -6678,9 +6663,9 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "2.6.1"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528"
checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9"
dependencies = [
"base64 0.22.1",
"brotli",
@@ -6705,9 +6690,9 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.6.1"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502"
checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -6719,9 +6704,9 @@ dependencies = [
[[package]]
name = "tauri-plugin"
version = "2.6.1"
version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee"
checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e"
dependencies = [
"anyhow",
"glob",
@@ -6908,9 +6893,9 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "2.11.1"
version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc"
checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef"
dependencies = [
"cookie",
"dpi",
@@ -6933,9 +6918,9 @@ dependencies = [
[[package]]
name = "tauri-runtime-wry"
version = "2.11.1"
version = "2.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0"
checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9"
dependencies = [
"gtk",
"http",
@@ -6959,9 +6944,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.9.1"
version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec"
checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95"
dependencies = [
"anyhow",
"brotli",
@@ -6988,7 +6973,7 @@ dependencies = [
"serde_with",
"swift-rs",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"toml 0.9.12+spec-1.1.0",
"url",
"urlpattern",
"uuid",
@@ -7016,7 +7001,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -7418,9 +7403,9 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.6.10"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.11.1",
"bytes",
@@ -7518,7 +7503,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7539,7 +7524,7 @@ dependencies = [
"once_cell",
"png 0.18.1",
"thiserror 2.0.18",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7611,7 +7596,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -8269,7 +8254,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -8795,7 +8780,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -9259,7 +9244,7 @@ checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"aes 0.8.4",
"arbitrary",
"bzip2 0.5.2",
"bzip2",
"constant_time_eq 0.3.1",
"crc32fast",
"crossbeam-utils",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.24.2"
version = "0.24.4"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
+106
View File
@@ -87,6 +87,8 @@ pub struct UpdateProfileRequest {
pub tags: Option<Vec<String>>,
pub extension_group_id: Option<String>,
pub proxy_bypass_rules: Option<Vec<String>>,
/// One of "Disabled", "Regular", "Encrypted".
pub sync_mode: Option<String>,
}
#[derive(Clone)]
@@ -215,6 +217,20 @@ struct OpenUrlRequest {
url: String,
}
#[derive(Debug, Deserialize, ToSchema)]
struct ImportCookiesRequest {
/// Raw cookie file content. Format is auto-detected: a JSON array
/// (Puppeteer / EditThisCookie style) or a Netscape `cookies.txt`.
content: String,
}
#[derive(Debug, Serialize, ToSchema)]
struct ImportCookiesResponse {
cookies_imported: usize,
cookies_replaced: usize,
errors: Vec<String>,
}
#[derive(OpenApi)]
#[openapi(
paths(
@@ -226,6 +242,7 @@ struct OpenUrlRequest {
run_profile,
open_url_in_profile,
kill_profile,
import_profile_cookies,
get_groups,
get_group,
create_group,
@@ -268,6 +285,8 @@ struct OpenUrlRequest {
RunProfileResponse,
RunProfileRequest,
OpenUrlRequest,
ImportCookiesRequest,
ImportCookiesResponse,
ProxySettings,
)),
tags(
@@ -277,6 +296,7 @@ struct OpenUrlRequest {
(name = "proxies", description = "Proxy management endpoints"),
(name = "vpns", description = "VPN management endpoints"),
(name = "browsers", description = "Browser management endpoints"),
(name = "cookies", description = "Cookie management endpoints"),
),
modifiers(&SecurityAddon),
)]
@@ -363,6 +383,7 @@ impl ApiServer {
.routes(routes!(run_profile))
.routes(routes!(open_url_in_profile))
.routes(routes!(kill_profile))
.routes(routes!(import_profile_cookies))
.routes(routes!(get_groups, create_group))
.routes(routes!(get_group, update_group, delete_group))
.routes(routes!(get_tags))
@@ -397,10 +418,15 @@ impl ApiServer {
.route("/events", get(ws_handler))
.with_state(ws_state);
let api_for_v1 = api.clone();
let app = Router::new()
.merge(v1_routes)
.nest("/ws", ws_routes)
.route("/openapi.json", get(move || async move { Json(api) }))
.route(
"/v1/openapi.json",
get(move || async move { Json(api_for_v1) }),
)
// Outermost layer: logs every request so customer reports show what
// their automation is actually calling, what the response status was,
// and how long it took. Never logs request bodies or auth headers.
@@ -929,6 +955,15 @@ async fn update_profile(
}
}
if let Some(sync_mode) = request.sync_mode {
if crate::sync::set_profile_sync_mode(state.app_handle.clone(), id.clone(), sync_mode)
.await
.is_err()
{
return Err(StatusCode::BAD_REQUEST);
}
}
// Return updated profile
get_profile(Path(id), State(state)).await
}
@@ -1818,6 +1853,77 @@ async fn kill_profile(
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
path = "/v1/profiles/{id}/cookies/import",
params(
("id" = String, Path, description = "Profile ID")
),
request_body = ImportCookiesRequest,
responses(
(status = 200, description = "Cookies imported successfully", body = ImportCookiesResponse),
(status = 400, description = "Invalid cookie file or unsupported browser"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 409, description = "Browser is currently running"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "cookies"
)]
async fn import_profile_cookies(
Path(id): Path<String>,
State(state): State<ApiServerState>,
Json(request): Json<ImportCookiesRequest>,
) -> Result<Json<ImportCookiesResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !profiles.iter().any(|p| p.id.to_string() == id) {
return Err(StatusCode::NOT_FOUND);
}
match crate::cookie_manager::CookieManager::import_cookies(
&state.app_handle,
&id,
&request.content,
)
.await
{
Ok(result) => {
if let Some(scheduler) = crate::sync::get_global_scheduler() {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
if profile.is_sync_enabled() {
let pid = id.clone();
tauri::async_runtime::spawn(async move {
scheduler.queue_profile_sync(pid).await;
});
}
}
}
Ok(Json(ImportCookiesResponse {
cookies_imported: result.cookies_imported,
cookies_replaced: result.cookies_replaced,
errors: result.errors,
}))
}
Err(e) => {
let msg = e.to_lowercase();
if msg.contains("running") {
Err(StatusCode::CONFLICT)
} else if msg.contains("no valid cookies") || msg.contains("unsupported browser") {
Err(StatusCode::BAD_REQUEST)
} else {
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
}
// API Handler - Download Browser
#[utoipa::path(
post,
+6 -5
View File
@@ -376,11 +376,12 @@ impl CamoufoxConfigBuilder {
(config, target_os)
};
// Add random window history length
config.insert(
"window.history.length".to_string(),
serde_json::json!(rng.random_range(1..=5)),
);
// Note: we used to spoof `window.history.length` to a random value in
// [1, 5] here. Newer Camoufox builds clamp the docShell session history
// to this value, which disables the toolbar back/forward buttons when
// the spoof rolls a small number. The fingerprint value drifts on every
// user navigation anyway, so a constant spoof is detectable and not
// worth the broken navigation UX.
// Add fonts
if !self.custom_fonts_only {
+125 -42
View File
@@ -222,10 +222,16 @@ impl CamoufoxManager {
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?;
// Parse the fingerprint config JSON
let fingerprint_config: HashMap<String, serde_json::Value> =
let mut fingerprint_config: HashMap<String, serde_json::Value> =
serde_json::from_str(&custom_config)
.map_err(|e| format!("Failed to parse fingerprint config: {e}"))?;
// Strip `window.history.length` even when present in a previously-saved
// fingerprint. Newer Camoufox clamps the docShell session history to the
// spoofed value, which disables the toolbar back/forward buttons. See
// the matching note in camoufox/config.rs.
fingerprint_config.remove("window.history.length");
// Convert to environment variables using CAMOU_CONFIG chunking
let env_vars = crate::camoufox::env_vars::config_to_env_vars(&fingerprint_config)
.map_err(|e| format!("Failed to convert config to env vars: {e}"))?;
@@ -264,13 +270,33 @@ impl CamoufoxManager {
args
);
// Spawn the browser process
// Spawn the browser process. Camoufox prints NSS/PSM and proxy failures
// to stderr (e.g. cert errors, CONNECT failures) and the user otherwise
// sees only an opaque "Secure Connection Failed" page — capture stderr
// to a per-launch file so diagnostics survive without a TTY.
let stderr_log_path = std::env::temp_dir().join(format!("camoufox-stderr-{}.log", profile.id));
let mut command = TokioCommand::new(&executable_path);
command
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
.stdout(Stdio::null());
match std::fs::File::create(&stderr_log_path) {
Ok(file) => {
log::info!(
"Camoufox stderr will be logged to: {}",
stderr_log_path.display()
);
command.stderr(Stdio::from(file));
}
Err(e) => {
log::warn!(
"Failed to open Camoufox stderr log {}: {e}",
stderr_log_path.display()
);
command.stderr(Stdio::null());
}
}
// Add environment variables
for (key, value) in &env_vars {
@@ -287,7 +313,7 @@ impl CamoufoxManager {
}
}
let child = command
let mut child = command
.spawn()
.map_err(|e| format!("Failed to spawn Camoufox process: {e}"))?;
@@ -296,6 +322,34 @@ impl CamoufoxManager {
log::info!("Camoufox launched with PID: {:?}", process_id);
// Watch the child so its exit status (signal / non-zero code) lands in
// the log. Without this, all we see is "PID X is no longer running" via
// the periodic sysinfo poll, with no clue why it died.
let watch_profile_path = profile_path.to_string();
tokio::spawn(async move {
match child.wait().await {
Ok(status) => {
if status.success() {
log::info!(
"Camoufox PID {:?} for {} exited cleanly (status=0)",
process_id,
watch_profile_path
);
} else {
log::warn!(
"Camoufox PID {:?} for {} exited abnormally: {}",
process_id,
watch_profile_path,
status
);
}
}
Err(e) => {
log::warn!("Failed to await Camoufox PID {:?} exit: {}", process_id, e);
}
}
});
// Store the instance
let instance = CamoufoxInstance {
id: instance_id.clone(),
@@ -557,28 +611,28 @@ impl CamoufoxManager {
for (id, instance) in inner.instances.iter() {
if let Some(process_id) = instance.process_id {
// Check if the process is still alive
if !self.is_server_running(process_id).await {
// Process is dead
// Camoufox instance is no longer running
log::info!(
"Camoufox instance {} (PID {}) is no longer running; profile_path={:?}",
id,
process_id,
instance.profile_path
);
dead_instances.push(id.clone());
instances_to_remove.push(id.clone());
}
} else {
// No process_id means it's likely a dead instance
// Camoufox instance has no PID, marking as dead
log::info!("Camoufox instance {} has no PID, marking as dead", id);
dead_instances.push(id.clone());
instances_to_remove.push(id.clone());
}
}
}
// Remove dead instances
if !instances_to_remove.is_empty() {
let mut inner = self.inner.lock().await;
for id in &instances_to_remove {
inner.instances.remove(id);
// Removed dead Camoufox instance
}
}
@@ -662,10 +716,11 @@ impl CamoufoxManager {
}
}
// Write explicit proxy + extension prefs to user.js so Camoufox always
// uses the local donut-proxy and picks up sideloaded extensions. user.js
// values override prefs.js on every launch, so this is always canonical.
if let Some(proxy_str) = &config.proxy {
// Patch user.js with Camoufox-specific overrides on every launch. This
// always runs (not gated on the proxy being set) because Camoufox's
// bundled camoufox.cfg ships defaults that break basic browser features
// and we need to override them per-profile.
{
let user_js_path = profile_path.join("user.js");
let mut prefs = String::new();
@@ -673,8 +728,12 @@ impl CamoufoxManager {
// re-emit so they never duplicate.
let managed_keys = [
"network.proxy.",
"network.http.http3.enable",
"network.http.http3.enabled",
"xpinstall.signatures.required",
"extensions.startupScanScopes",
"browser.sessionhistory.max_entries",
"browser.sessionhistory.max_total_viewers",
];
if let Ok(existing) = std::fs::read_to_string(&user_js_path) {
for line in existing.lines() {
@@ -685,6 +744,15 @@ impl CamoufoxManager {
}
}
// Camoufox's bundled camoufox.cfg sets these to 0, which makes
// docShell remember zero prior pages and leaves the toolbar
// back/forward buttons permanently disabled no matter how much
// the user navigates. Restore Firefox defaults.
prefs.push_str(
"user_pref(\"browser.sessionhistory.max_entries\", 50);\n\
user_pref(\"browser.sessionhistory.max_total_viewers\", -1);\n",
);
// Required for sideloaded extensions:
// - signatures.required=false accepts unsigned .xpi (Camoufox is built
// without MOZ_REQUIRE_SIGNING so this is honored).
@@ -695,36 +763,51 @@ impl CamoufoxManager {
user_pref(\"extensions.startupScanScopes\", 1);\n",
);
if let Ok(parsed) = url::Url::parse(proxy_str) {
let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port().unwrap_or(8080);
let scheme = parsed.scheme();
// Disable HTTP/3 / QUIC. Camoufox always sits behind the local
// donut-proxy, and Firefox-150's QUIC stack bypasses configured HTTP
// proxies and goes direct UDP to the remote host. With an upstream
// proxy that's the only allowed egress, that traffic silently fails
// and pages won't load. (Chromium suppresses QUIC under a proxy on
// its own, so Wayfern doesn't need the equivalent toggle.) Both
// pref names are emitted because they've been renamed across FF
// versions and either could be the active one at runtime.
prefs.push_str(
"user_pref(\"network.http.http3.enable\", false);\n\
user_pref(\"network.http.http3.enabled\", false);\n",
);
if scheme == "socks5" || scheme == "socks4" {
prefs.push_str(&format!(
"user_pref(\"network.proxy.type\", 1);\n\
user_pref(\"network.proxy.socks\", \"{host}\");\n\
user_pref(\"network.proxy.socks_port\", {port});\n\
user_pref(\"network.proxy.socks_version\", {});\n\
user_pref(\"network.proxy.socks_remote_dns\", true);\n",
if scheme == "socks5" { 5 } else { 4 }
));
} else {
// HTTP/HTTPS proxy
prefs.push_str(&format!(
"user_pref(\"network.proxy.type\", 1);\n\
user_pref(\"network.proxy.http\", \"{host}\");\n\
user_pref(\"network.proxy.http_port\", {port});\n\
user_pref(\"network.proxy.ssl\", \"{host}\");\n\
user_pref(\"network.proxy.ssl_port\", {port});\n\
user_pref(\"network.proxy.no_proxies_on\", \"\");\n"
));
}
if let Some(proxy_str) = &config.proxy {
if let Ok(parsed) = url::Url::parse(proxy_str) {
let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port().unwrap_or(8080);
let scheme = parsed.scheme();
if let Err(e) = std::fs::write(&user_js_path, prefs) {
log::warn!("Failed to write user.js: {e}");
if scheme == "socks5" || scheme == "socks4" {
prefs.push_str(&format!(
"user_pref(\"network.proxy.type\", 1);\n\
user_pref(\"network.proxy.socks\", \"{host}\");\n\
user_pref(\"network.proxy.socks_port\", {port});\n\
user_pref(\"network.proxy.socks_version\", {});\n\
user_pref(\"network.proxy.socks_remote_dns\", true);\n",
if scheme == "socks5" { 5 } else { 4 }
));
} else {
// HTTP/HTTPS proxy
prefs.push_str(&format!(
"user_pref(\"network.proxy.type\", 1);\n\
user_pref(\"network.proxy.http\", \"{host}\");\n\
user_pref(\"network.proxy.http_port\", {port});\n\
user_pref(\"network.proxy.ssl\", \"{host}\");\n\
user_pref(\"network.proxy.ssl_port\", {port});\n\
user_pref(\"network.proxy.no_proxies_on\", \"\");\n"
));
}
}
}
if let Err(e) = std::fs::write(&user_js_path, prefs) {
log::warn!("Failed to write user.js: {e}");
}
}
self
+2 -1
View File
@@ -99,7 +99,7 @@ use settings_manager::{
};
use sync::{
check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities,
cancel_profile_sync, check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities,
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile,
is_vpn_in_use_by_synced_profile, request_profile_sync, rollover_encryption_for_all_entities,
set_e2e_password, set_extension_group_sync_enabled, set_extension_sync_enabled,
@@ -2057,6 +2057,7 @@ pub fn run() {
get_sync_settings,
save_sync_settings,
set_profile_sync_mode,
cancel_profile_sync,
request_profile_sync,
set_proxy_sync_enabled,
set_group_sync_enabled,
+91
View File
@@ -1145,6 +1145,25 @@ impl McpServer {
"required": ["profile_id"]
}),
},
// Cookie management tools
McpTool {
name: "import_profile_cookies".to_string(),
description: "Import cookies into a Wayfern or Camoufox profile from a JSON array (Puppeteer / EditThisCookie format) or a Netscape cookies.txt. Format is auto-detected. The browser must not be running.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"profile_id": {
"type": "string",
"description": "The UUID of the target profile"
},
"content": {
"type": "string",
"description": "Raw cookie file content (JSON array or Netscape cookies.txt)"
}
},
"required": ["profile_id", "content"]
}),
},
// Team lock tools
McpTool {
name: "get_team_locks".to_string(),
@@ -1674,6 +1693,8 @@ impl McpServer {
.handle_assign_extension_group_to_profile(arguments)
.await
}
// Cookie management
"import_profile_cookies" => self.handle_import_profile_cookies(arguments).await,
// Team lock tools
"get_team_locks" => self.handle_get_team_locks().await,
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
@@ -2855,6 +2876,74 @@ impl McpServer {
}))
}
// Cookie management handlers
async fn handle_import_profile_cookies(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let profile_id = arguments
.get("profile_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing profile_id".to_string(),
})?;
let content = arguments
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing content".to_string(),
})?;
let app_handle = {
let inner = self.inner.lock().await;
inner
.app_handle
.as_ref()
.ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
})?
.clone()
};
let result =
crate::cookie_manager::CookieManager::import_cookies(&app_handle, profile_id, content)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to import cookies: {e}"),
})?;
if let Some(scheduler) = crate::sync::get_global_scheduler() {
let profile_manager = crate::profile::manager::ProfileManager::instance();
if let Ok(profiles) = profile_manager.list_profiles() {
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) {
if profile.is_sync_enabled() {
let pid = profile_id.to_string();
tauri::async_runtime::spawn(async move {
scheduler.queue_profile_sync(pid).await;
});
}
}
}
}
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!(
"Import complete: {} imported, {} replaced, {} parse error(s)",
result.cookies_imported,
result.cookies_replaced,
result.errors.len()
)
}]
}))
}
// VPN management handlers
async fn handle_import_vpn(
&self,
@@ -4968,6 +5057,8 @@ mod tests {
assert!(tool_names.contains(&"delete_extension"));
assert!(tool_names.contains(&"delete_extension_group"));
assert!(tool_names.contains(&"assign_extension_group_to_profile"));
// Cookie tools
assert!(tool_names.contains(&"import_profile_cookies"));
// Team lock tools
assert!(tool_names.contains(&"get_team_locks"));
assert!(tool_names.contains(&"get_team_lock_status"));
+39 -23
View File
@@ -377,9 +377,18 @@ impl ProfileManager {
log::info!("Profile '{name}' created successfully with ID: {profile_id}");
// Create user.js with common Firefox preferences and apply proxy settings if provided
// Skip for ephemeral profiles since the data dir is created at launch time
if !ephemeral {
// `apply_proxy_settings_to_profile` writes a Firefox-style user.js
// with the upstream proxy host. That is wrong for both supported
// browser types:
// - Camoufox: camoufox_manager rewrites user.js at every launch with
// the local donut-proxy host; writing the upstream here leaves a
// stale, wrong proxy in user.js until the next launch.
// - Wayfern: Chromium gets its proxy via `--proxy-pac-url=` at launch
// (see wayfern_manager.rs) and never reads user.js.
// So we only call it for any unrecognized browser type that might be
// a true Firefox-family target (none currently). Ephemeral profiles
// skip regardless because their data dir is created at launch time.
if !ephemeral && !matches!(browser, "camoufox" | "wayfern") {
if let Some(proxy_id_ref) = &proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
self.apply_proxy_settings_to_profile(&profile_data_dir, &proxy_settings, None)?;
@@ -1236,18 +1245,34 @@ impl ProfileManager {
}
}
// Update on-disk browser profile config immediately
if let Some(proxy_id_ref) = &proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to apply proxy settings: {e}").into()
})?;
// Update on-disk browser profile config immediately.
// Both supported browser types ignore this write (Camoufox rewrites
// user.js at launch with the local donut-proxy host, Wayfern takes its
// proxy via `--proxy-pac-url=` and never reads user.js), and for
// Camoufox specifically writing the upstream host here would leave a
// stale, wrong proxy in user.js until the next launch.
if !matches!(profile.browser.as_str(), "camoufox" | "wayfern") {
if let Some(proxy_id_ref) = &proxy_id {
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to apply proxy settings: {e}").into()
})?;
} else {
// Proxy ID provided but proxy not found, disable proxy
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.disable_proxy_settings_in_profile(&profile_path)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to disable proxy settings: {e}").into()
})?;
}
} else {
// Proxy ID provided but proxy not found, disable proxy
// No proxy ID provided, disable proxy
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
@@ -1256,15 +1281,6 @@ impl ProfileManager {
format!("Failed to disable proxy settings: {e}").into()
})?;
}
} else {
// No proxy ID provided, disable proxy
let profiles_dir = self.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
self
.disable_proxy_settings_in_profile(&profile_path)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to disable proxy settings: {e}").into()
})?;
}
// Emit profile update event so frontend UIs can refresh immediately (e.g. proxy manager)
+49 -5
View File
@@ -1147,14 +1147,17 @@ pub async fn handle_proxy_connection(
}
}
let _ = handle_connect_from_buffer(
if let Err(e) = handle_connect_from_buffer(
stream,
full_request,
upstream_url,
bypass_matcher,
blocklist_matcher,
)
.await;
.await
{
log::warn!("CONNECT tunnel ended with error: {e}");
}
return;
}
@@ -1449,6 +1452,13 @@ async fn handle_connect_from_buffer(
tracker.record_request(&domain, 0, 0);
}
log::info!(
"CONNECT {}:{} (upstream={})",
target_host,
target_port,
upstream_url.as_deref().unwrap_or("DIRECT")
);
// Connect to target (directly or via upstream proxy).
// Returns a BoxedAsyncStream so all upstream types (plain TCP, SOCKS,
// Shadowsocks) share the same bidirectional-copy tunnel code below.
@@ -1503,12 +1513,46 @@ async fn handle_connect_from_buffer(
let mut buffer = [0u8; 4096];
let n = proxy_stream.read(&mut buffer).await?;
let response = String::from_utf8_lossy(&buffer[..n]);
let response_full = String::from_utf8_lossy(&buffer[..n]).to_string();
let status_line = response_full.lines().next().unwrap_or("").to_string();
if !response.starts_with("HTTP/1.1 200") && !response.starts_with("HTTP/1.0 200") {
return Err(format!("Upstream proxy CONNECT failed: {}", response).into());
if !response_full.starts_with("HTTP/1.1 200")
&& !response_full.starts_with("HTTP/1.0 200")
{
log::warn!(
"Upstream CONNECT to {}:{} via {}:{} rejected: {}",
target_host,
target_port,
proxy_host,
proxy_port,
status_line
);
return Err(format!("Upstream proxy CONNECT failed: {response_full}").into());
}
// Detect the buffer-drop race where the upstream returned the
// 200 response coalesced with destination bytes — those bytes
// would otherwise be silently discarded and the browser would
// see a TLS stream missing its first record.
let header_end_in_buffer = response_full.find("\r\n\r\n").map(|i| i + 4);
if let Some(end) = header_end_in_buffer {
if end < n {
log::warn!(
"Upstream CONNECT response coalesced {} byte(s) of payload — these would be dropped without forwarding",
n - end
);
}
}
log::info!(
"Upstream CONNECT to {}:{} via {}:{} accepted ({})",
target_host,
target_port,
proxy_host,
proxy_port,
status_line
);
Box::new(proxy_stream)
}
"socks4" | "socks5" => {
+1 -1
View File
@@ -52,7 +52,7 @@ pub struct AppSettings {
#[serde(default)]
pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt
#[serde(default)]
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ru", or None for system default
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default
#[serde(default)]
pub window_resize_warning_dismissed: bool,
#[serde(default)]
+241 -169
View File
@@ -10,11 +10,48 @@ use chrono::{DateTime, Utc};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Instant;
use tokio::sync::{Mutex as TokioMutex, Semaphore};
lazy_static::lazy_static! {
static ref SYNC_CANCEL_FLAGS: StdMutex<HashMap<String, Arc<AtomicBool>>> =
StdMutex::new(HashMap::new());
}
fn register_sync_cancel(profile_id: &str) -> Arc<AtomicBool> {
let mut map = SYNC_CANCEL_FLAGS.lock().unwrap();
let flag = Arc::new(AtomicBool::new(false));
map.insert(profile_id.to_string(), flag.clone());
flag
}
fn clear_sync_cancel(profile_id: &str) {
SYNC_CANCEL_FLAGS.lock().unwrap().remove(profile_id);
}
pub fn request_sync_cancel(profile_id: &str) -> bool {
if let Some(flag) = SYNC_CANCEL_FLAGS.lock().unwrap().get(profile_id) {
flag.store(true, Ordering::SeqCst);
true
} else {
false
}
}
struct SyncCancelGuard(String);
impl Drop for SyncCancelGuard {
fn drop(&mut self) {
clear_sync_cancel(&self.0);
}
}
#[tauri::command]
pub async fn cancel_profile_sync(profile_id: String) -> Result<bool, String> {
Ok(request_sync_cancel(&profile_id))
}
/// Upload/download concurrency limit
const SYNC_CONCURRENCY: usize = 32;
@@ -391,6 +428,9 @@ impl SyncEngine {
let profile_dir = profiles_dir.join(profile.id.to_string());
let profile_id = profile.id.to_string();
let cancel_flag = register_sync_cancel(&profile_id);
let _cancel_guard = SyncCancelGuard(profile_id.clone());
// Determine team key prefix for team profiles
let key_prefix = Self::get_team_key_prefix(profile).await;
@@ -514,10 +554,16 @@ impl SyncEngine {
&diff.files_to_upload,
encryption_key.as_ref(),
&key_prefix,
&cancel_flag,
)
.await?;
}
if cancel_flag.load(Ordering::Relaxed) {
log::info!("Sync cancelled for profile {} after uploads", profile_id);
return Err(SyncError::Cancelled);
}
// Perform downloads
if !diff.files_to_download.is_empty() {
self
@@ -529,10 +575,16 @@ impl SyncEngine {
&diff.files_to_download,
encryption_key.as_ref(),
&key_prefix,
&cancel_flag,
)
.await?;
}
if cancel_flag.load(Ordering::Relaxed) {
log::info!("Sync cancelled for profile {} after downloads", profile_id);
return Err(SyncError::Cancelled);
}
// Delete local files that don't exist remotely (when remote is newer)
for path in &diff.files_to_delete_local {
let file_path = profile_dir.join(path);
@@ -823,6 +875,7 @@ impl SyncEngine {
files: &[super::manifest::ManifestFileEntry],
encryption_key: Option<&[u8; 32]>,
key_prefix: &str,
cancel_flag: &Arc<AtomicBool>,
) -> SyncResult<()> {
if files.is_empty() {
return Ok(());
@@ -930,6 +983,13 @@ impl SyncEngine {
let save_counter = Arc::new(AtomicU64::new(0));
for file in &files_to_process {
if cancel_flag.load(Ordering::Relaxed) {
log::info!(
"Upload cancelled for profile {} before scheduling more files",
profile_id_owned
);
break;
}
let sem = semaphore.clone();
let file_path = profile_dir.join(&file.path);
let relative_path = file.path.clone();
@@ -958,6 +1018,7 @@ impl SyncEngine {
let resume_state = resume_state.clone();
let save_counter = save_counter.clone();
let profile_dir_clone = profile_dir.clone();
let cancel_flag_task = cancel_flag.clone();
let content_type = mime_guess::from_path(&file.path)
.first()
.map(|m| m.to_string());
@@ -965,6 +1026,10 @@ impl SyncEngine {
handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.unwrap();
if cancel_flag_task.load(Ordering::Relaxed) {
return Err((relative_path, "cancelled".to_string(), false));
}
let data = match fs::read(&file_path) {
Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !critical => {
@@ -1095,6 +1160,7 @@ impl SyncEngine {
files: &[super::manifest::ManifestFileEntry],
encryption_key: Option<&[u8; 32]>,
key_prefix: &str,
cancel_flag: &Arc<AtomicBool>,
) -> SyncResult<()> {
if files.is_empty() {
return Ok(());
@@ -1194,6 +1260,13 @@ impl SyncEngine {
let save_counter = Arc::new(AtomicU64::new(0));
for file in &files_to_process {
if cancel_flag.load(Ordering::Relaxed) {
log::info!(
"Download cancelled for profile {} before scheduling more files",
profile_id_owned
);
break;
}
let sem = semaphore.clone();
let file_path = profile_dir.join(&file.path);
let relative_path = file.path.clone();
@@ -1222,13 +1295,21 @@ impl SyncEngine {
let resume_state = resume_state.clone();
let save_counter = save_counter.clone();
let profile_dir_clone = profile_dir.clone();
let cancel_flag_task = cancel_flag.clone();
handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.unwrap();
if cancel_flag_task.load(Ordering::Relaxed) {
return Err((relative_path, "cancelled".to_string(), false));
}
// Retry loop for network downloads
let mut last_err = String::new();
for attempt in 0..MAX_FILE_RETRIES {
if cancel_flag_task.load(Ordering::Relaxed) {
return Err((relative_path, "cancelled".to_string(), false));
}
match client.download_bytes(&url).await {
Ok(data) => {
let write_data = if let Some(ref key) = enc_key {
@@ -2361,6 +2442,8 @@ impl SyncEngine {
);
}
if !manifest.files.is_empty() {
let cancel_flag = register_sync_cancel(profile_id);
let _cancel_guard = SyncCancelGuard(profile_id.to_string());
self
.download_profile_files(
app_handle,
@@ -2370,6 +2453,7 @@ impl SyncEngine {
&manifest.files,
encryption_key.as_ref(),
key_prefix,
&cancel_flag,
)
.await?;
}
@@ -2506,8 +2590,46 @@ impl SyncEngine {
profiles_to_check.len()
);
// For each remote profile, check if it exists locally and download if missing
// For each remote profile, check if it exists locally and download if missing.
// Skip any profile that has a tombstone — a leftover manifest under a
// tombstoned id means delete_prefix raced or partially failed, and
// re-downloading it here is what surfaced the "Browsing keeps re-syncing"
// bug after a delete.
for (profile_id, key_prefix) in &profiles_to_check {
let personal_tombstone = format!("tombstones/profiles/{}.json", profile_id);
let has_personal_tombstone = matches!(
self.client.stat(&personal_tombstone).await,
Ok(stat) if stat.exists
);
let team_tombstone_key = if key_prefix.is_empty() {
None
} else {
Some(format!(
"{}tombstones/profiles/{}.json",
key_prefix, profile_id
))
};
let has_team_tombstone = if let Some(ref tk) = team_tombstone_key {
matches!(self.client.stat(tk).await, Ok(stat) if stat.exists)
} else {
false
};
if has_personal_tombstone || has_team_tombstone {
log::info!(
"Skipping download of tombstoned profile {} (clearing leftover remote files)",
profile_id
);
let prefix = format!("{}profiles/{}/", key_prefix, profile_id);
if let Err(e) = self.client.delete_prefix(&prefix, None).await {
log::warn!(
"Failed to clear stale remote files for tombstoned profile {}: {}",
profile_id,
e
);
}
continue;
}
match self
.download_profile_if_missing(app_handle, profile_id, key_prefix)
.await
@@ -2571,6 +2693,24 @@ impl SyncEngine {
};
if has_personal_tombstone || has_team_tombstone {
// Originator guard: re-read the profile right before deleting. If the
// local user disabled sync between the snapshot above and this stat
// call, they're the one who wrote this tombstone — keep their local
// copy. Tombstones must delete remote-originated changes, never the
// sender's own data. (Caused mass local deletion in v0.24.x.)
let still_sync_enabled = profile_manager
.list_profiles()
.unwrap_or_default()
.iter()
.find(|p| p.id.to_string() == *pid)
.is_some_and(|p| p.is_sync_enabled());
if !still_sync_enabled {
log::info!(
"Profile {} has a tombstone but sync is no longer enabled locally — keeping local copy (originating device)",
pid
);
continue;
}
log::info!(
"Profile {} has remote tombstone, deleting locally (deleted on another device)",
pid
@@ -2948,6 +3088,11 @@ pub async fn set_profile_sync_mode(
return Err("Cannot modify sync settings for a cross-OS profile".to_string());
}
let enabling_now = new_mode != SyncMode::Disabled;
if enabling_now && profile.process_id.is_some() {
return Err(serde_json::json!({ "code": "PROFILE_RUNNING" }).to_string());
}
if profile.ephemeral {
return Err("Cannot enable sync for an ephemeral profile".to_string());
}
@@ -3029,6 +3174,22 @@ pub async fn set_profile_sync_mode(
let _ = events::emit("profiles-changed", ());
// When (re-)enabling sync, clear any stale tombstone from a previous
// disable on this device. Otherwise the next reconcile on another
// device — or even a race on this one — would see the tombstone and
// delete the freshly re-uploaded data.
if enabling {
if let Ok(engine) = SyncEngine::create_from_settings(&app_handle).await {
let key_prefix = SyncEngine::get_team_key_prefix(&profile).await;
let personal_tombstone = format!("tombstones/profiles/{}.json", profile_id);
let _ = engine.client.delete(&personal_tombstone, None).await;
if !key_prefix.is_empty() {
let team_tombstone = format!("{}tombstones/profiles/{}.json", key_prefix, profile_id);
let _ = engine.client.delete(&team_tombstone, None).await;
}
}
}
if enabling {
let is_running = profile.process_id.is_some();
@@ -3084,28 +3245,25 @@ pub async fn set_profile_sync_mode(
log::warn!("Scheduler not initialized, sync will not start");
}
} else {
// Delete remote data when disabling sync
// Delete remote data when disabling sync. Awaited (not spawned) so the
// tombstone write completes before this command returns. A previous
// tokio::spawn here allowed the tombstone-write to land *after* a fast
// user-triggered re-enable's tombstone-clear, re-introducing the
// tombstone and tripping the reconcile-pass deletion of a profile the
// user had just re-enabled (e.g. Personal (z.ai) on 2026-05-20).
if old_mode != SyncMode::Disabled {
let profile_id_clone = profile_id.clone();
let app_handle_clone = app_handle.clone();
tokio::spawn(async move {
match SyncEngine::create_from_settings(&app_handle_clone).await {
Ok(engine) => {
if let Err(e) = engine.delete_profile(&profile_id_clone).await {
log::warn!(
"Failed to delete profile {} from sync: {}",
profile_id_clone,
e
);
} else {
log::info!("Profile {} deleted from sync service", profile_id_clone);
}
}
Err(e) => {
log::debug!("Sync not configured, skipping remote deletion: {}", e);
match SyncEngine::create_from_settings(&app_handle).await {
Ok(engine) => {
if let Err(e) = engine.delete_profile(&profile_id).await {
log::warn!("Failed to delete profile {} from sync: {}", profile_id, e);
} else {
log::info!("Profile {} deleted from sync service", profile_id);
}
}
});
Err(e) => {
log::debug!("Sync not configured, skipping remote deletion: {}", e);
}
}
}
let _ = events::emit(
@@ -3183,6 +3341,28 @@ pub async fn sync_profile(app_handle: tauri::AppHandle, profile_id: String) -> R
trigger_sync_for_profile(app_handle, profile_id).await
}
/// Ensure the device has either a cloud login or a self-hosted server URL + token.
/// Returns a JSON error code string consumable by the frontend translator.
async fn ensure_sync_configured(app_handle: &tauri::AppHandle) -> Result<(), String> {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if cloud_logged_in {
return Ok(());
}
let manager = SettingsManager::instance();
let settings = manager.load_settings().map_err(|e| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
.to_string()
})?;
if settings.sync_server_url.is_none() {
return Err(serde_json::json!({ "code": "SYNC_NOT_CONFIGURED" }).to_string());
}
let token = manager.get_sync_token(app_handle).await.ok().flatten();
if token.is_none() {
return Err(serde_json::json!({ "code": "SYNC_NOT_CONFIGURED" }).to_string());
}
Ok(())
}
pub async fn trigger_sync_for_profile(
app_handle: tauri::AppHandle,
profile_id: String,
@@ -3222,43 +3402,29 @@ pub async fn set_proxy_sync_enabled(
let proxy = proxies
.iter()
.find(|p| p.id == proxy_id)
.ok_or_else(|| format!("Proxy with ID '{proxy_id}' not found"))?;
.ok_or_else(|| serde_json::json!({ "code": "PROXY_NOT_FOUND" }).to_string())?;
// Block modifying sync for cloud-managed proxies
if proxy.is_cloud_managed {
return Err("Cannot modify sync for a cloud-managed proxy".to_string());
return Err(serde_json::json!({ "code": "CANNOT_MODIFY_CLOUD_MANAGED_PROXY" }).to_string());
}
// If disabling, check if proxy is used by any synced profile
if !enabled && is_proxy_used_by_synced_profile(&proxy_id) {
return Err("Sync cannot be disabled while this proxy is used by synced profiles".to_string());
return Err(serde_json::json!({ "code": "SYNC_LOCKED_BY_PROFILE" }).to_string());
}
// If enabling, check that sync settings are configured
if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if !cloud_logged_in {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() {
return Err(
"Sync server not configured. Please configure sync settings first.".to_string(),
);
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
return Err("Sync token not configured. Please configure sync settings first.".to_string());
}
}
ensure_sync_configured(&app_handle).await?;
}
let new_last_sync = if enabled { proxy.last_sync } else { None };
proxy_manager.set_stored_proxy_sync_state(&proxy_id, enabled, new_last_sync)?;
proxy_manager
.set_stored_proxy_sync_state(&proxy_id, enabled, new_last_sync)
.map_err(|e| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e } }).to_string()
})?;
let _ = events::emit("stored-proxies-changed", ());
@@ -3299,36 +3465,18 @@ pub async fn set_group_sync_enabled(
groups
.iter()
.find(|g| g.id == group_id)
.ok_or_else(|| format!("Group with ID '{group_id}' not found"))?
.ok_or_else(|| serde_json::json!({ "code": "GROUP_NOT_FOUND" }).to_string())?
.clone()
};
// If disabling, check if group is used by any synced profile
if !enabled && is_group_used_by_synced_profile(&group_id) {
return Err("Sync cannot be disabled while this group is used by synced profiles".to_string());
return Err(serde_json::json!({ "code": "SYNC_LOCKED_BY_PROFILE" }).to_string());
}
// If enabling, check that sync settings are configured
if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if !cloud_logged_in {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() {
return Err(
"Sync server not configured. Please configure sync settings first.".to_string(),
);
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
return Err("Sync token not configured. Please configure sync settings first.".to_string());
}
}
ensure_sync_configured(&app_handle).await?;
}
let mut updated_group = group.clone();
@@ -3341,7 +3489,10 @@ pub async fn set_group_sync_enabled(
{
let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap();
if let Err(e) = group_manager.update_group_internal(&updated_group) {
return Err(format!("Failed to update group: {e}"));
return Err(
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
.to_string(),
);
}
}
@@ -3392,35 +3543,17 @@ pub async fn set_vpn_sync_enabled(
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage
.load_config(&vpn_id)
.map_err(|e| format!("VPN with ID '{vpn_id}' not found: {e}"))?
.map_err(|_| serde_json::json!({ "code": "VPN_NOT_FOUND" }).to_string())?
};
// If disabling, check if VPN is used by any synced profile
if !enabled && is_vpn_used_by_synced_profile(&vpn_id) {
return Err("Sync cannot be disabled while this VPN is used by synced profiles".to_string());
return Err(serde_json::json!({ "code": "SYNC_LOCKED_BY_PROFILE" }).to_string());
}
// If enabling, check that sync settings are configured
if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if !cloud_logged_in {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() {
return Err(
"Sync server not configured. Please configure sync settings first.".to_string(),
);
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
return Err("Sync token not configured. Please configure sync settings first.".to_string());
}
}
ensure_sync_configured(&app_handle).await?;
}
let last_sync = if enabled { vpn.last_sync } else { None };
@@ -3429,7 +3562,10 @@ pub async fn set_vpn_sync_enabled(
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage
.update_sync_fields(&vpn_id, enabled, last_sync)
.map_err(|e| format!("Failed to update VPN sync: {e}"))?;
.map_err(|e| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
.to_string()
})?;
}
let _ = events::emit("vpn-configs-changed", ());
@@ -3526,48 +3662,10 @@ pub fn get_unsynced_entity_counts() -> Result<UnsyncedEntityCounts, String> {
#[tauri::command]
pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Result<(), String> {
// Enable sync for all eligible profiles. Without this the user would see
// groups/proxies/vpns syncing while their profiles stay local-only — the
// long-standing source of issue #352. Encrypted mode wins when an E2E
// password is already configured; otherwise we fall back to plain Regular.
{
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?;
let desired_mode = if encryption::has_e2e_password() {
SyncMode::Encrypted
} else {
SyncMode::Regular
};
let desired_mode_str = match desired_mode {
SyncMode::Encrypted => "Encrypted",
SyncMode::Regular => "Regular",
SyncMode::Disabled => "Disabled",
};
for profile in &profiles {
// Skip profiles that are already syncing (any non-Disabled mode),
// ephemeral profiles (data wipes on quit, sync is meaningless), and
// cross-OS profiles (the OS-specific binary isn't installed locally
// so a sync round-trip would be one-sided).
if profile.sync_mode != SyncMode::Disabled || profile.ephemeral || profile.is_cross_os() {
continue;
}
if let Err(e) = set_profile_sync_mode(
app_handle.clone(),
profile.id.to_string(),
desired_mode_str.to_string(),
)
.await
{
log::warn!(
"Failed to enable sync for profile {} ({}): {e}",
profile.name,
profile.id
);
}
}
}
// Intentionally excludes profiles: enabling profile sync uploads the entire
// browser data dir per profile, which is destructive if the user expected
// an opt-in. Profile sync stays under explicit per-profile control via
// set_profile_sync_mode. This command only touches metadata-sized entities.
// Enable sync for all unsynced proxies
{
@@ -3664,26 +3762,11 @@ pub async fn set_extension_sync_enabled(
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager
.get_extension(&extension_id)
.map_err(|e| format!("Extension with ID '{extension_id}' not found: {e}"))?
.map_err(|_| serde_json::json!({ "code": "EXTENSION_NOT_FOUND" }).to_string())?
};
if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if !cloud_logged_in {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() {
return Err(
"Sync server not configured. Please configure sync settings first.".to_string(),
);
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
return Err("Sync token not configured. Please configure sync settings first.".to_string());
}
}
ensure_sync_configured(&app_handle).await?;
}
let mut updated_ext = ext;
@@ -3696,7 +3779,10 @@ pub async fn set_extension_sync_enabled(
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager
.update_extension_internal(&updated_ext)
.map_err(|e| format!("Failed to update extension sync: {e}"))?;
.map_err(|e| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
.to_string()
})?;
}
let _ = events::emit("extensions-changed", ());
@@ -3720,26 +3806,11 @@ pub async fn set_extension_group_sync_enabled(
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager
.get_group(&extension_group_id)
.map_err(|e| format!("Extension group with ID '{extension_group_id}' not found: {e}"))?
.map_err(|_| serde_json::json!({ "code": "EXTENSION_GROUP_NOT_FOUND" }).to_string())?
};
if enabled {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if !cloud_logged_in {
let manager = SettingsManager::instance();
let settings = manager
.load_settings()
.map_err(|e| format!("Failed to load settings: {e}"))?;
if settings.sync_server_url.is_none() {
return Err(
"Sync server not configured. Please configure sync settings first.".to_string(),
);
}
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
if token.is_none() {
return Err("Sync token not configured. Please configure sync settings first.".to_string());
}
}
ensure_sync_configured(&app_handle).await?;
}
let mut updated_group = group;
@@ -3750,9 +3821,10 @@ pub async fn set_extension_group_sync_enabled(
{
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
manager
.update_group_internal(&updated_group)
.map_err(|e| format!("Failed to update extension group sync: {e}"))?;
manager.update_group_internal(&updated_group).map_err(|e| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
.to_string()
})?;
}
let _ = events::emit("extensions-changed", ());
+13 -3
View File
@@ -35,6 +35,16 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/startupCache/**",
"**/safebrowsing/**",
"**/storage/temporary/**",
"**/storage/default/*/cache/**",
"**/datareporting/**",
"**/saved-telemetry-pings/**",
"**/sessionstore-backups/**",
"**/sessions/**",
"**/serviceworker.txt",
"**/AlternateServices.bin",
"**/SiteSecurityServiceState.bin",
"**/favicons.sqlite",
"**/favicons.sqlite-*",
"**/crashes/**",
"**/minidumps/**",
"*.tmp",
@@ -52,9 +62,9 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/BrowserMetrics*",
"**/.DS_Store",
".donut-sync/**",
// Local-only marker recording when Wayfern last refreshed this profile's
// fingerprint. Each device decides its own refresh cadence, so syncing
// this would cause one device's refresh to silence others.
// Orphaned local-only marker from earlier rollover-based fingerprint
// regeneration. Keep excluding it so any markers left on disk from
// prior builds never get uploaded.
".last-fp-refresh",
];
+3 -3
View File
@@ -11,9 +11,9 @@ pub use encryption::{
check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password,
};
pub use engine::{
enable_extension_group_sync_if_needed, enable_group_sync_if_needed, enable_proxy_sync_if_needed,
enable_sync_for_all_entities, enable_vpn_sync_if_needed, get_unsynced_entity_counts,
is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
cancel_profile_sync, enable_extension_group_sync_if_needed, enable_group_sync_if_needed,
enable_proxy_sync_if_needed, enable_sync_for_all_entities, enable_vpn_sync_if_needed,
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured,
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, request_profile_sync,
rollover_encryption_for_all_entities, set_extension_group_sync_enabled,
+10 -3
View File
@@ -716,16 +716,18 @@ impl SyncScheduler {
match entity_type.as_str() {
"profile" => {
let profile_manager = ProfileManager::instance();
let has_profile = {
let local_sync_enabled = {
if let Ok(profiles) = profile_manager.list_profiles() {
let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok();
profile_uuid.is_some_and(|uuid| profiles.iter().any(|p| p.id == uuid))
profile_uuid
.and_then(|uuid| profiles.into_iter().find(|p| p.id == uuid))
.is_some_and(|p| p.is_sync_enabled())
} else {
false
}
};
if has_profile {
if local_sync_enabled {
log::info!(
"Profile {} was deleted remotely, deleting locally",
entity_id
@@ -733,6 +735,11 @@ impl SyncScheduler {
if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) {
log::warn!("Failed to delete tombstoned profile {}: {}", entity_id, e);
}
} else {
log::info!(
"Profile {} has a tombstone but sync is no longer enabled locally — keeping local copy",
entity_id
);
}
}
"proxy" => {
+2
View File
@@ -166,6 +166,7 @@ pub enum SyncError {
SerializationError(String),
ConflictError(String),
InvalidData(String),
Cancelled,
}
impl std::fmt::Display for SyncError {
@@ -178,6 +179,7 @@ impl std::fmt::Display for SyncError {
SyncError::SerializationError(msg) => write!(f, "Serialization error: {msg}"),
SyncError::ConflictError(msg) => write!(f, "Conflict error: {msg}"),
SyncError::InvalidData(msg) => write!(f, "Invalid data: {msg}"),
SyncError::Cancelled => write!(f, "Sync cancelled by user"),
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.24.2",
"version": "0.24.4",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+1 -1
View File
@@ -1174,7 +1174,7 @@ export default function Home() {
failed_count: payload.failed_count ?? 0,
phase: payload.phase,
},
{ id: toastId },
{ id: toastId, profileId: payload.profile_id },
);
}
});
@@ -42,7 +42,7 @@ export function DeleteConfirmationDialog({
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
+29 -8
View File
@@ -73,6 +73,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { Extension, ExtensionGroup } from "@/types";
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
@@ -308,7 +309,11 @@ export function ExtensionManagementDialog({
);
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showErrorToast(
parseBackendError(err)
? translateBackendError(t, err)
: t("proxies.management.updateSyncFailed"),
);
} finally {
setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: false }));
}
@@ -331,7 +336,11 @@ export function ExtensionManagementDialog({
);
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showErrorToast(
parseBackendError(err)
? translateBackendError(t, err)
: t("proxies.management.updateSyncFailed"),
);
} finally {
setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: false }));
}
@@ -589,9 +598,15 @@ export function ExtensionManagementDialog({
}),
),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) {
showErrorToast(t("proxies.management.updateSyncFailed"));
const firstRejection = results.find((r) => r.status === "rejected") as
| PromiseRejectedResult
| undefined;
if (firstRejection) {
showErrorToast(
parseBackendError(firstRejection.reason)
? translateBackendError(t, firstRejection.reason)
: t("proxies.management.updateSyncFailed"),
);
} else {
showSuccessToast(
targetEnabled
@@ -614,9 +629,15 @@ export function ExtensionManagementDialog({
}),
),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) {
showErrorToast(t("proxies.management.updateSyncFailed"));
const firstRejection = results.find((r) => r.status === "rejected") as
| PromiseRejectedResult
| undefined;
if (firstRejection) {
showErrorToast(
parseBackendError(firstRejection.reason)
? translateBackendError(t, firstRejection.reason)
: t("proxies.management.updateSyncFailed"),
);
} else {
showSuccessToast(
targetEnabled
+12 -5
View File
@@ -57,6 +57,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { GroupWithCount, ProfileGroup } from "@/types";
import { RippleButton } from "./ui/ripple";
@@ -262,8 +263,8 @@ export function GroupManagementDialog({
} catch (error) {
console.error("Failed to toggle sync:", error);
showErrorToast(
error instanceof Error
? error.message
parseBackendError(error)
? translateBackendError(t, error)
: t("proxies.management.updateSyncFailed"),
);
} finally {
@@ -529,9 +530,15 @@ export function GroupManagementDialog({
}),
),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) {
showErrorToast(t("proxies.management.updateSyncFailed"));
const firstRejection = results.find((r) => r.status === "rejected") as
| PromiseRejectedResult
| undefined;
if (firstRejection) {
showErrorToast(
parseBackendError(firstRejection.reason)
? translateBackendError(t, firstRejection.reason)
: t("proxies.management.updateSyncFailed"),
);
} else {
showSuccessToast(
targetEnabled
+15 -2
View File
@@ -120,6 +120,7 @@ export function IntegrationsDialog({
const [isMcpStarting, setIsMcpStarting] = useState(false);
const [agents, setAgents] = useState<McpAgentInfo[]>([]);
const [busyAgentIds, setBusyAgentIds] = useState<Set<string>>(new Set());
const [apiPortDraft, setApiPortDraft] = useState<string>("10108");
const { termsAccepted } = useWayfernTerms();
@@ -127,6 +128,7 @@ export function IntegrationsDialog({
try {
const loaded = await invoke<AppSettings>("get_app_settings");
setSettings(loaded);
setApiPortDraft(String(loaded.api_port ?? ""));
} catch (e) {
console.error("Failed to load settings:", e);
}
@@ -370,13 +372,24 @@ export function IntegrationsDialog({
<div className="flex items-center gap-2">
<Input
type="number"
value={settings.api_port}
value={apiPortDraft}
onChange={(e) => {
setApiPortDraft(e.target.value);
const val = Number.parseInt(e.target.value, 10);
if (!Number.isNaN(val)) {
if (
!Number.isNaN(val) &&
val >= 1 &&
val <= 65535
) {
setSettings({ ...settings, api_port: val });
}
}}
onBlur={() => {
const val = Number.parseInt(apiPortDraft, 10);
if (Number.isNaN(val) || val < 1 || val > 65535) {
setApiPortDraft(String(settings.api_port));
}
}}
className="w-24 font-mono"
min={1}
max={65535}
+1 -1
View File
@@ -12,7 +12,7 @@ type Props = ButtonProps & {
export const LoadingButton = ({ isLoading, className, ...props }: Props) => {
return (
<UIButton
className={cn("grid place-items-center", className)}
className={cn("inline-flex items-center justify-center", className)}
{...props}
disabled={props.disabled || isLoading}
>
+8 -10
View File
@@ -691,7 +691,7 @@ const TagsCell = React.memo<{
);
return (
<div className="w-40 h-6 cursor-pointer">
<div className="w-full h-6 cursor-pointer">
<Tooltip>
<TooltipTrigger asChild>{ButtonContent}</TooltipTrigger>
{hiddenCount > 0 && (
@@ -717,7 +717,7 @@ const TagsCell = React.memo<{
return (
<div
className={cn(
"w-40 h-6 relative",
"w-full h-6 relative",
isDisabled && "opacity-60 pointer-events-none",
)}
>
@@ -925,19 +925,17 @@ const NoteCell = React.memo<{
}, [openNoteEditorFor, profile.id]);
const displayNote = effectiveNote ?? "";
const trimmedNote =
displayNote.length > 12 ? `${displayNote.slice(0, 12)}...` : displayNote;
const showTooltip = displayNote.length > 12 || displayNote.length > 0;
const showTooltip = displayNote.length > 0;
if (openNoteEditorFor !== profile.id) {
return (
<div className="w-24 min-h-6">
<div className="w-full min-h-6">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"flex items-start px-2 py-1 min-h-6 w-full bg-transparent rounded border-none text-left",
"flex items-center px-2 py-1 min-h-6 w-full min-w-0 bg-transparent rounded border-none text-left",
isDisabled
? "opacity-60 cursor-not-allowed"
: "cursor-pointer hover:bg-accent/50",
@@ -951,11 +949,11 @@ const NoteCell = React.memo<{
>
<span
className={cn(
"text-sm wrap-break-word",
"text-sm truncate block w-full",
!effectiveNote && "text-muted-foreground",
)}
>
{effectiveNote ? trimmedNote : t("profiles.note.empty")}
{effectiveNote ? displayNote : t("profiles.note.empty")}
</span>
</button>
</TooltipTrigger>
@@ -974,7 +972,7 @@ const NoteCell = React.memo<{
return (
<div
className={cn(
"w-24 relative",
"w-full relative",
isDisabled && "opacity-60 pointer-events-none",
)}
>
+42 -5
View File
@@ -24,6 +24,7 @@ import {
LuShield,
LuShieldCheck,
LuTrash2,
LuUpload,
LuUsers,
LuX,
} from "react-icons/lu";
@@ -582,8 +583,9 @@ function ProfileInfoLayout({
const deleteAction = findAction("delete");
const fingerprintAction = findAction("fingerprint");
const cookiesAction =
findAction("manage cookies") ?? findAction("copy cookies");
const cookiesManageAction = findAction("manage cookies");
const cookiesCopyAction = findAction("copy cookies");
const cookiesAction = cookiesManageAction ?? cookiesCopyAction;
const extensionAction = findAction("extension");
const syncAction = findAction("sync");
const _launchHookAction = findAction("hook") ?? findAction("launch hook");
@@ -905,6 +907,8 @@ function ProfileInfoLayout({
profile={profile}
isRunning={isRunning}
isDisabled={isDisabled}
onCopyCookies={cookiesCopyAction?.onClick}
onImportCookies={cookiesManageAction?.onClick}
t={t}
/>
)}
@@ -1435,11 +1439,16 @@ function ExtensionsSectionInline({
function CookiesSectionInline({
profile,
isRunning,
isDisabled,
onCopyCookies,
onImportCookies,
t,
}: {
profile: BrowserProfile;
isRunning: boolean;
isDisabled: boolean;
onCopyCookies?: () => void;
onImportCookies?: () => void;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
type CookieStats = {
@@ -1483,9 +1492,37 @@ function CookiesSectionInline({
return (
<div className="flex flex-col gap-3 min-h-0 flex-1">
<div className="flex items-center gap-2 text-sm font-semibold">
<LuCookie className="size-4" />
{t("profileInfo.sections.cookies")}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-semibold">
<LuCookie className="size-4" />
{t("profileInfo.sections.cookies")}
</div>
<div className="flex items-center gap-2">
{onImportCookies && (
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5"
disabled={isDisabled || isRunning}
onClick={onImportCookies}
>
<LuUpload className="size-3.5" />
{t("cookies.import.title")}
</Button>
)}
{onCopyCookies && (
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5"
disabled={isDisabled}
onClick={onCopyCookies}
>
<LuCopy className="size-3.5" />
{t("profiles.actions.copyCookies")}
</Button>
)}
</div>
</div>
<p className="text-xs text-muted-foreground">
{t("profileInfo.sectionDesc.cookies")}
+26 -13
View File
@@ -67,6 +67,7 @@ import {
} from "@/components/ui/tooltip";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types";
@@ -394,8 +395,8 @@ export function ProxyManagementDialog({
} catch (error) {
console.error("Failed to toggle sync:", error);
showErrorToast(
error instanceof Error
? error.message
parseBackendError(error)
? translateBackendError(t, error)
: t("proxies.management.updateSyncFailed"),
);
} finally {
@@ -458,8 +459,8 @@ export function ProxyManagementDialog({
} catch (error) {
console.error("Failed to toggle VPN sync:", error);
showErrorToast(
error instanceof Error
? error.message
parseBackendError(error)
? translateBackendError(t, error)
: t("proxies.management.updateSyncFailed"),
);
} finally {
@@ -1010,9 +1011,15 @@ export function ProxyManagementDialog({
}),
),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) {
showErrorToast(t("proxies.management.updateSyncFailed"));
const firstRejection = results.find((r) => r.status === "rejected") as
| PromiseRejectedResult
| undefined;
if (firstRejection) {
showErrorToast(
parseBackendError(firstRejection.reason)
? translateBackendError(t, firstRejection.reason)
: t("proxies.management.updateSyncFailed"),
);
} else {
showSuccessToast(
targetEnabled
@@ -1039,9 +1046,15 @@ export function ProxyManagementDialog({
}),
),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) {
showErrorToast(t("vpns.management.updateSyncFailed"));
const firstRejection = results.find((r) => r.status === "rejected") as
| PromiseRejectedResult
| undefined;
if (firstRejection) {
showErrorToast(
parseBackendError(firstRejection.reason)
? translateBackendError(t, firstRejection.reason)
: t("proxies.management.updateSyncFailed"),
);
} else {
showSuccessToast(
targetEnabled
@@ -1055,7 +1068,7 @@ export function ProxyManagementDialog({
return (
<>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-[min(95vw,1600px)] max-h-[90vh] flex flex-col">
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
{!subPage && (
<DialogHeader>
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
@@ -1170,7 +1183,7 @@ export function ProxyManagementDialog({
} as React.CSSProperties
}
>
<Table className="min-w-max">
<Table className="w-full">
<TableHeader className="sticky top-0 z-10 bg-background">
{proxiesTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
@@ -1251,7 +1264,7 @@ export function ProxyManagementDialog({
} as React.CSSProperties
}
>
<Table className="min-w-max">
<Table className="w-full">
<TableHeader className="sticky top-0 z-10 bg-background">
{vpnsTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
+1
View File
@@ -464,6 +464,7 @@ export function SettingsDialog({
| "fr"
| "zh"
| "ja"
| "ko"
| "ru"),
);
setOriginalLanguage(selectedLanguage);
+69 -20
View File
@@ -1,9 +1,12 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FiWifi } from "react-icons/fi";
import { LuLayers, LuPuzzle, LuShield, LuUsers } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -19,6 +22,8 @@ interface UnsyncedEntityCounts {
proxies: number;
groups: number;
vpns: number;
extensions: number;
extension_groups: number;
}
interface SyncAllDialogProps {
@@ -67,27 +72,55 @@ export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
}
}, [onClose, t]);
const totalCount =
(counts?.proxies ?? 0) + (counts?.groups ?? 0) + (counts?.vpns ?? 0);
const items = useMemo(() => {
if (!counts) return [];
return [
{
key: "proxies",
count: counts.proxies,
label: t("syncAll.labels.proxies"),
Icon: FiWifi,
},
{
key: "vpns",
count: counts.vpns,
label: t("syncAll.labels.vpns"),
Icon: LuShield,
},
{
key: "groups",
count: counts.groups,
label: t("syncAll.labels.groups"),
Icon: LuUsers,
},
{
key: "extensions",
count: counts.extensions,
label: t("syncAll.labels.extensions"),
Icon: LuPuzzle,
},
{
key: "extensionGroups",
count: counts.extension_groups,
label: t("syncAll.labels.extensionGroups"),
Icon: LuLayers,
},
].filter((item) => item.count > 0);
}, [counts, t]);
// Don't show if there's nothing to sync
const totalCount = items.reduce((sum, item) => sum + item.count, 0);
// Don't render anything when there's nothing to sync — the parent
// mounts this dialog eagerly after login, so silent-close is correct.
if (!isLoading && totalCount === 0) {
return null;
}
const parts: string[] = [];
if (counts?.proxies && counts.proxies > 0) {
parts.push(t("syncAll.proxies", { count: counts.proxies }));
}
if (counts?.groups && counts.groups > 0) {
parts.push(t("syncAll.groups", { count: counts.groups }));
}
if (counts?.vpns && counts.vpns > 0) {
parts.push(t("syncAll.vpns", { count: counts.vpns }));
}
return (
<Dialog open={isOpen && totalCount > 0} onOpenChange={onClose}>
<Dialog
open={isOpen && (isLoading || totalCount > 0)}
onOpenChange={onClose}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("syncAll.title")}</DialogTitle>
@@ -99,10 +132,26 @@ export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div>
) : (
<div className="py-4">
<p className="text-sm text-muted-foreground">
{t("syncAll.itemsList", { items: parts.join(", ") })}
</p>
<div className="grid grid-cols-2 gap-2 py-2">
{items.map(({ key, count, label, Icon }) => (
<div
key={key}
className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/50 p-3 transition-colors hover:bg-card"
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Icon className="size-4" />
</div>
<div className="min-w-0 flex-1 text-sm font-medium truncate">
{label}
</div>
<Badge
variant="secondary"
className="shrink-0 tabular-nums px-2"
>
{count}
</Badge>
</div>
))}
</div>
)}
+7 -9
View File
@@ -11,19 +11,18 @@ const MotionThumb = motion.create(SwitchPrimitive.Thumb);
type AnimatedSwitchProps = React.ComponentProps<typeof SwitchPrimitive.Root>;
/**
* Toggle switch with a thumb that slides between the off (left) and on
* (right) positions and squashes wider while pressed. Animated via Framer
* Motion no layout shift when the parent's width changes, and the
* pressed state is purely visual so external onCheckedChange semantics
* stay identical to a Radix Switch.
* Switch whose thumb actually slides between off and on. The Root flips
* its flex alignment on `data-state=checked`, which moves the Thumb's
* layout box; Framer Motion's `layout` prop tweens between the two
* positions. The thumb also squashes wider while pressed.
*/
function AnimatedSwitch({ className, ...props }: AnimatedSwitchProps) {
return (
<SwitchPrimitive.Root
data-slot="animated-switch"
className={cn(
"peer relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent",
"bg-input data-[state=checked]:bg-primary",
"peer relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center justify-start rounded-full border border-transparent px-[2px]",
"bg-input data-[state=checked]:bg-primary data-[state=checked]:justify-end",
"transition-colors duration-200 ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-50",
@@ -39,8 +38,7 @@ function AnimatedSwitch({ className, ...props }: AnimatedSwitchProps) {
)}
layout
transition={{ type: "spring", stiffness: 700, damping: 32, mass: 0.5 }}
whileTap={{ width: 22 }}
style={{ marginLeft: 2, marginRight: 2 }}
whileTap={{ width: 20 }}
/>
</SwitchPrimitive.Root>
);
+3
View File
@@ -5,6 +5,7 @@ import en from "./locales/en.json";
import es from "./locales/es.json";
import fr from "./locales/fr.json";
import ja from "./locales/ja.json";
import ko from "./locales/ko.json";
import pt from "./locales/pt.json";
import ru from "./locales/ru.json";
import zh from "./locales/zh.json";
@@ -16,6 +17,7 @@ export const SUPPORTED_LANGUAGES = [
{ code: "fr", name: "French", nativeName: "Français" },
{ code: "zh", name: "Chinese", nativeName: "中文" },
{ code: "ja", name: "Japanese", nativeName: "日本語" },
{ code: "ko", name: "Korean", nativeName: "한국어" },
{ code: "ru", name: "Russian", nativeName: "Русский" },
] as const;
@@ -61,6 +63,7 @@ const resources = {
fr: { translation: fr },
zh: { translation: zh },
ja: { translation: ja },
ko: { translation: ko },
ru: { translation: ru },
};
+16 -8
View File
@@ -1070,16 +1070,16 @@
"syncAll": {
"title": "Enable Sync for Existing Items",
"description": "You have items that are not being synced. Would you like to enable sync for all of them?",
"itemsList": "Items not synced: {{items}}",
"proxies": "{{count}} proxy",
"proxies_plural": "{{count}} proxies",
"groups": "{{count}} group",
"groups_plural": "{{count}} groups",
"vpns": "{{count}} VPN",
"vpns_plural": "{{count}} VPNs",
"enableAll": "Enable All",
"skip": "Skip",
"success": "Sync enabled for all items"
"success": "Sync enabled for all items",
"labels": {
"proxies": "Proxies",
"vpns": "VPNs",
"groups": "Groups",
"extensions": "Extensions",
"extensionGroups": "Extension Groups"
}
},
"crossOs": {
"viewOnly": "This profile was created on {{os}} and is not supported on this system",
@@ -1788,6 +1788,14 @@
"profileLocked": "Profile is locked. Enter the password first.",
"invalidProfileId": "Invalid profile id",
"passwordTooShort": "Password must be at least {{min}} characters",
"proxyNotFound": "Proxy not found",
"groupNotFound": "Group not found",
"vpnNotFound": "VPN not found",
"extensionNotFound": "Extension not found",
"extensionGroupNotFound": "Extension group not found",
"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.",
"internal": "Something went wrong: {{detail}}",
"invalidLaunchHookUrl": "Invalid launch hook URL. Use a full http:// or https:// URL.",
"cookieDbLocked": "Could not read cookies — the database is locked. Close the browser and try again.",
+16 -8
View File
@@ -1070,16 +1070,16 @@
"syncAll": {
"title": "Activar sincronización para elementos existentes",
"description": "Tienes elementos que no se están sincronizando. ¿Te gustaría activar la sincronización para todos?",
"itemsList": "Elementos no sincronizados: {{items}}",
"proxies": "{{count}} proxy",
"proxies_plural": "{{count}} proxies",
"groups": "{{count}} grupo",
"groups_plural": "{{count}} grupos",
"vpns": "{{count}} VPN",
"vpns_plural": "{{count}} VPNs",
"enableAll": "Activar todos",
"skip": "Omitir",
"success": "Sincronización activada para todos los elementos"
"success": "Sincronización activada para todos los elementos",
"labels": {
"proxies": "Proxies",
"vpns": "VPN",
"groups": "Grupos",
"extensions": "Extensiones",
"extensionGroups": "Grupos de extensiones"
}
},
"crossOs": {
"viewOnly": "Este perfil fue creado en {{os}} y no es compatible con este sistema",
@@ -1788,6 +1788,14 @@
"profileLocked": "El perfil está bloqueado. Introduce la contraseña primero.",
"invalidProfileId": "ID de perfil no válido",
"passwordTooShort": "La contraseña debe tener al menos {{min}} caracteres",
"proxyNotFound": "Proxy no encontrado",
"groupNotFound": "Grupo no encontrado",
"vpnNotFound": "VPN no encontrada",
"extensionNotFound": "Extensión no encontrada",
"extensionGroupNotFound": "Grupo de extensiones no encontrado",
"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.",
"internal": "Algo salió mal: {{detail}}",
"invalidLaunchHookUrl": "URL del hook de inicio no válida. Usa una URL completa http:// o https://.",
"cookieDbLocked": "No se pudieron leer las cookies — la base de datos está bloqueada. Cierra el navegador e inténtalo de nuevo.",
+16 -8
View File
@@ -1070,16 +1070,16 @@
"syncAll": {
"title": "Activer la synchronisation pour les éléments existants",
"description": "Vous avez des éléments qui ne sont pas synchronisés. Voulez-vous activer la synchronisation pour tous ?",
"itemsList": "Éléments non synchronisés : {{items}}",
"proxies": "{{count}} proxy",
"proxies_plural": "{{count}} proxies",
"groups": "{{count}} groupe",
"groups_plural": "{{count}} groupes",
"vpns": "{{count}} VPN",
"vpns_plural": "{{count}} VPNs",
"enableAll": "Tout activer",
"skip": "Ignorer",
"success": "Synchronisation activée pour tous les éléments"
"success": "Synchronisation activée pour tous les éléments",
"labels": {
"proxies": "Proxies",
"vpns": "VPN",
"groups": "Groupes",
"extensions": "Extensions",
"extensionGroups": "Groupes d'extensions"
}
},
"crossOs": {
"viewOnly": "Ce profil a été créé sur {{os}} et n'est pas pris en charge sur ce système",
@@ -1788,6 +1788,14 @@
"profileLocked": "Le profil est verrouillé. Entrez d'abord le mot de passe.",
"invalidProfileId": "Identifiant de profil non valide",
"passwordTooShort": "Le mot de passe doit comporter au moins {{min}} caractères",
"proxyNotFound": "Proxy introuvable",
"groupNotFound": "Groupe introuvable",
"vpnNotFound": "VPN introuvable",
"extensionNotFound": "Extension introuvable",
"extensionGroupNotFound": "Groupe d'extensions introuvable",
"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é.",
"internal": "Une erreur s'est produite : {{detail}}",
"invalidLaunchHookUrl": "URL du hook de lancement invalide. Utilisez une URL http:// ou https:// complète.",
"cookieDbLocked": "Impossible de lire les cookies — la base de données est verrouillée. Fermez le navigateur et réessayez.",
+16 -8
View File
@@ -1070,16 +1070,16 @@
"syncAll": {
"title": "既存アイテムの同期を有効にする",
"description": "同期されていないアイテムがあります。すべての同期を有効にしますか?",
"itemsList": "未同期アイテム: {{items}}",
"proxies": "{{count}}個のプロキシ",
"proxies_plural": "{{count}}個のプロキシ",
"groups": "{{count}}個のグループ",
"groups_plural": "{{count}}個のグループ",
"vpns": "{{count}}個のVPN",
"vpns_plural": "{{count}}個のVPN",
"enableAll": "すべて有効にする",
"skip": "スキップ",
"success": "すべてのアイテムの同期が有効になりました"
"success": "すべてのアイテムの同期が有効になりました",
"labels": {
"proxies": "プロキシ",
"vpns": "VPN",
"groups": "グループ",
"extensions": "拡張機能",
"extensionGroups": "拡張機能グループ"
}
},
"crossOs": {
"viewOnly": "このプロファイルは{{os}}で作成されたもので、このシステムではサポートされていません",
@@ -1788,6 +1788,14 @@
"profileLocked": "プロファイルはロックされています。先にパスワードを入力してください。",
"invalidProfileId": "無効なプロファイルIDです",
"passwordTooShort": "パスワードは {{min}} 文字以上必要です",
"proxyNotFound": "プロキシが見つかりません",
"groupNotFound": "グループが見つかりません",
"vpnNotFound": "VPNが見つかりません",
"extensionNotFound": "拡張機能が見つかりません",
"extensionGroupNotFound": "拡張機能グループが見つかりません",
"cannotModifyCloudManagedProxy": "クラウド管理のプロキシの同期は変更できません",
"syncLockedByProfile": "同期済みプロファイルで使用中のため、同期を無効にできません",
"syncNotConfigured": "同期が設定されていません。サインインするか、セルフホストサーバーを設定してください。",
"internal": "問題が発生しました: {{detail}}",
"invalidLaunchHookUrl": "起動フックURLが無効です。完全な http:// または https:// URL を使用してください。",
"cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。",
File diff suppressed because it is too large Load Diff
+16 -8
View File
@@ -1070,16 +1070,16 @@
"syncAll": {
"title": "Ativar sincronização para itens existentes",
"description": "Você tem itens que não estão sendo sincronizados. Gostaria de ativar a sincronização para todos?",
"itemsList": "Itens não sincronizados: {{items}}",
"proxies": "{{count}} proxy",
"proxies_plural": "{{count}} proxies",
"groups": "{{count}} grupo",
"groups_plural": "{{count}} grupos",
"vpns": "{{count}} VPN",
"vpns_plural": "{{count}} VPNs",
"enableAll": "Ativar todos",
"skip": "Pular",
"success": "Sincronização ativada para todos os itens"
"success": "Sincronização ativada para todos os itens",
"labels": {
"proxies": "Proxies",
"vpns": "VPNs",
"groups": "Grupos",
"extensions": "Extensões",
"extensionGroups": "Grupos de extensões"
}
},
"crossOs": {
"viewOnly": "Este perfil foi criado em {{os}} e não é compatível com este sistema",
@@ -1788,6 +1788,14 @@
"profileLocked": "O perfil está bloqueado. Digite a senha primeiro.",
"invalidProfileId": "ID de perfil inválido",
"passwordTooShort": "A senha deve ter pelo menos {{min}} caracteres",
"proxyNotFound": "Proxy não encontrado",
"groupNotFound": "Grupo não encontrado",
"vpnNotFound": "VPN não encontrada",
"extensionNotFound": "Extensão não encontrada",
"extensionGroupNotFound": "Grupo de extensões não encontrado",
"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.",
"internal": "Algo deu errado: {{detail}}",
"invalidLaunchHookUrl": "URL do hook de inicialização inválida. Use uma URL completa http:// ou https://.",
"cookieDbLocked": "Não foi possível ler os cookies — o banco de dados está bloqueado. Feche o navegador e tente novamente.",
+16 -8
View File
@@ -1070,16 +1070,16 @@
"syncAll": {
"title": "Включить синхронизацию для существующих элементов",
"description": "У вас есть элементы, которые не синхронизируются. Хотите включить синхронизацию для всех?",
"itemsList": "Несинхронизированные элементы: {{items}}",
"proxies": "{{count}} прокси",
"proxies_plural": "{{count}} прокси",
"groups": "{{count}} группа",
"groups_plural": "{{count}} групп",
"vpns": "{{count}} VPN",
"vpns_plural": "{{count}} VPN",
"enableAll": "Включить все",
"skip": "Пропустить",
"success": "Синхронизация включена для всех элементов"
"success": "Синхронизация включена для всех элементов",
"labels": {
"proxies": "Прокси",
"vpns": "VPN",
"groups": "Группы",
"extensions": "Расширения",
"extensionGroups": "Группы расширений"
}
},
"crossOs": {
"viewOnly": "Этот профиль был создан на {{os}} и не поддерживается в этой системе",
@@ -1788,6 +1788,14 @@
"profileLocked": "Профиль заблокирован. Сначала введите пароль.",
"invalidProfileId": "Недействительный идентификатор профиля",
"passwordTooShort": "Пароль должен быть не короче {{min}} символов",
"proxyNotFound": "Прокси не найден",
"groupNotFound": "Группа не найдена",
"vpnNotFound": "VPN не найден",
"extensionNotFound": "Расширение не найдено",
"extensionGroupNotFound": "Группа расширений не найдена",
"cannotModifyCloudManagedProxy": "Невозможно изменить синхронизацию для облачного прокси",
"syncLockedByProfile": "Невозможно отключить синхронизацию, пока используется синхронизированными профилями",
"syncNotConfigured": "Синхронизация не настроена. Войдите или настройте собственный сервер.",
"internal": "Что-то пошло не так: {{detail}}",
"invalidLaunchHookUrl": "Неверный URL хука запуска. Используйте полный URL http:// или https://.",
"cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.",
+16 -8
View File
@@ -1070,16 +1070,16 @@
"syncAll": {
"title": "为现有项目启用同步",
"description": "您有未同步的项目。是否要为所有项目启用同步?",
"itemsList": "未同步项目: {{items}}",
"proxies": "{{count}} 个代理",
"proxies_plural": "{{count}} 个代理",
"groups": "{{count}} 个分组",
"groups_plural": "{{count}} 个分组",
"vpns": "{{count}} 个 VPN",
"vpns_plural": "{{count}} 个 VPN",
"enableAll": "全部启用",
"skip": "跳过",
"success": "已为所有项目启用同步"
"success": "已为所有项目启用同步",
"labels": {
"proxies": "代理",
"vpns": "VPN",
"groups": "分组",
"extensions": "扩展",
"extensionGroups": "扩展分组"
}
},
"crossOs": {
"viewOnly": "此配置文件在 {{os}} 上创建,不受此系统支持",
@@ -1788,6 +1788,14 @@
"profileLocked": "配置文件已锁定。请先输入密码。",
"invalidProfileId": "配置文件 ID 无效",
"passwordTooShort": "密码至少需要 {{min}} 个字符",
"proxyNotFound": "未找到代理",
"groupNotFound": "未找到分组",
"vpnNotFound": "未找到 VPN",
"extensionNotFound": "未找到扩展",
"extensionGroupNotFound": "未找到扩展分组",
"cannotModifyCloudManagedProxy": "无法修改云管理代理的同步",
"syncLockedByProfile": "在被已同步的配置文件使用时无法禁用同步",
"syncNotConfigured": "同步未配置。请先登录或配置自托管服务器。",
"internal": "出现问题:{{detail}}",
"invalidLaunchHookUrl": "启动钩子 URL 无效。请使用完整的 http:// 或 https:// URL。",
"cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。",
+24
View File
@@ -20,6 +20,14 @@ export type BackendErrorCode =
| "COOKIE_DB_LOCKED"
| "COOKIE_DB_UNAVAILABLE"
| "SELF_HOSTED_REQUIRES_LOGOUT"
| "PROXY_NOT_FOUND"
| "GROUP_NOT_FOUND"
| "VPN_NOT_FOUND"
| "EXTENSION_NOT_FOUND"
| "EXTENSION_GROUP_NOT_FOUND"
| "CANNOT_MODIFY_CLOUD_MANAGED_PROXY"
| "SYNC_LOCKED_BY_PROFILE"
| "SYNC_NOT_CONFIGURED"
| "INTERNAL_ERROR";
export interface BackendError {
@@ -96,6 +104,22 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.cookieDbUnavailable");
case "SELF_HOSTED_REQUIRES_LOGOUT":
return t("backendErrors.selfHostedRequiresLogout");
case "PROXY_NOT_FOUND":
return t("backendErrors.proxyNotFound");
case "GROUP_NOT_FOUND":
return t("backendErrors.groupNotFound");
case "VPN_NOT_FOUND":
return t("backendErrors.vpnNotFound");
case "EXTENSION_NOT_FOUND":
return t("backendErrors.extensionNotFound");
case "EXTENSION_GROUP_NOT_FOUND":
return t("backendErrors.extensionGroupNotFound");
case "CANNOT_MODIFY_CLOUD_MANAGED_PROXY":
return t("backendErrors.cannotModifyCloudManagedProxy");
case "SYNC_LOCKED_BY_PROFILE":
return t("backendErrors.syncLockedByProfile");
case "SYNC_NOT_CONFIGURED":
return t("backendErrors.syncNotConfigured");
case "INTERNAL_ERROR":
return t("backendErrors.internal", {
detail: parsed.params?.detail ?? "",
+11 -1
View File
@@ -1,3 +1,4 @@
import { invoke } from "@tauri-apps/api/core";
import React from "react";
import { type ExternalToast, toast as sonnerToast } from "sonner";
import { UnifiedToast } from "@/components/custom-toast";
@@ -259,7 +260,7 @@ export function showSyncProgressToast(
failed_count: number;
phase: string;
},
options?: { id?: string },
options?: { id?: string; profileId?: string },
) {
return showToast({
type: "sync-progress",
@@ -268,6 +269,15 @@ export function showSyncProgressToast(
id: options?.id,
duration: Number.POSITIVE_INFINITY,
onCancel: () => {
if (options?.profileId) {
// Fire-and-forget — backend flips the cancel flag for the in-flight
// upload/download loops to drain.
void invoke("cancel_profile_sync", {
profileId: options.profileId,
}).catch((err: unknown) => {
console.error("Failed to cancel sync:", err);
});
}
if (options?.id) {
dismissToast(options.id);
}