Compare commits

..

7 Commits

Author SHA1 Message Date
zhom eb7e88e867 docs: update readme 2025-07-09 06:12:08 +04:00
zhom e5f0621599 refactor: temporarily remove camoufox-js 2025-07-09 06:04:52 +04:00
zhom d6e940b29f style: copy 2025-07-09 06:02:28 +04:00
zhom 6dfa69608f refactor: switch nodecar to commonjs 2025-07-09 05:58:47 +04:00
zhom add4f6d3f8 refactor: popular camoufox data via env variable 2025-07-08 19:11:30 +04:00
zhom a1403c88f9 style: copy 2025-07-08 17:36:58 +04:00
zhom c9974a8071 style: copy 2025-07-08 06:41:09 +04:00
375 changed files with 21719 additions and 164132 deletions
@@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
Before finishing the task and showing summary, always run "pnpm format && pnpm lint && pnpm test" at the root of the project to ensure that you don't finish with broken application.
+6
View File
@@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
Don't leave comments that don't add value
+6
View File
@@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
Do not duplicate code unless you have a very good reason to do so. It is important that the same logic is not duplicated multiple times
@@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
After your changes, instead of running specific tests or linting specific files, run "pnpm format && pnpm lint && pnpm test". It means that you first format the code, then lint it, then test it, so that no part is broken after your changes.
-13
View File
@@ -1,13 +0,0 @@
# macOS code signing + notarization for `pnpm tauri build`.
# Loaded into the build environment via scripts/run-with-env.mjs (and direnv via .envrc).
# APPLE_SIGNING_IDENTITY: the exact name of your Developer ID Application
# certificate as it appears in `security find-identity -v -p codesigning`.
# Example: "Developer ID Application: Your Name (TEAMID)"
# APPLE_ID + APPLE_PASSWORD + APPLE_TEAM_ID: credentials for notarytool.
# APPLE_PASSWORD must be an app-specific password from appleid.apple.com,
# not your real Apple ID password.
APPLE_TEAM_ID=
APPLE_ID=
APPLE_PASSWORD=
APPLE_SIGNING_IDENTITY=
-5
View File
@@ -1,5 +0,0 @@
use flake
# Load .env on top of the flake's environment so APPLE_SIGNING_IDENTITY,
# APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID etc. are available to `tauri build`
# and any other tools spawned from this directory.
dotenv_if_exists .env
+42
View File
@@ -0,0 +1,42 @@
---
name: "Bug report"
about: Report a bug
---
<!--
Hi there! To expedite issue processing please search open and closed issues before submitting a new one. Existing issues often contain information about workarounds, resolution, or progress updates.
-->
# Bug Report
## Description
<!-- A clear and concise description of the problem. -->
## Is this a regression?
<!-- Did this behavior use to work in the previous version? -->
## Minimal Reproduction
<!-- Clear steps to re-produce the issue. -->
1.
2.
3.
## Your Environment
<!-- Please provide as much information as you feel comfortable to help us understand the issue better -->
## Exception or Error or Screenshot
<!-- Please provide any error messages, stack traces, or screenshots that might help -->
<pre><code>
<!-- Paste error logs here -->
</code></pre>
## Additional Context
<!-- Add any other context about the problem here. -->
@@ -0,0 +1,34 @@
---
name: "Feature request"
about: Suggest a feature
---
# Feature Request
## Description
<!-- A clear and concise description of the problem or missing capability. -->
## Describe the solution you'd like
<!-- If you have a solution in mind, please describe it. -->
## Describe alternatives you've considered
<!-- Have you considered any alternative solutions or workarounds? -->
## Use Case
<!-- Describe the specific use case and how this feature would benefit users. -->
## Priority
<!-- How important is this feature to you? -->
- [ ] Low - Nice to have
- [ ] Medium - Would improve my workflow
- [ ] High - Critical for my use case
## Additional Context
<!-- Add any other context, mockups, or examples about the feature request here. -->
-63
View File
@@ -1,63 +0,0 @@
name: Bug Report
description: Something isn't working
labels: ["bug"]
body:
- type: textarea
id: description
attributes:
label: What happened?
placeholder: Describe the bug. What did you expect vs what actually happened?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
placeholder: |
1. Go to ...
2. Click on ...
3. See error
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating System
options:
- macOS (Apple Silicon)
- macOS (Intel)
- Windows
- Linux
validations:
required: true
- type: input
id: version
attributes:
label: Donut Browser version
placeholder: e.g. 0.17.6 or nightly-2026-03-21
validations:
required: true
- type: dropdown
id: browser
attributes:
label: Which browser is affected?
options:
- Wayfern
- Camoufox
- Both
- Not browser-specific
validations:
required: true
- type: textarea
id: logs
attributes:
label: Error logs or screenshots
description: Run from terminal to get logs. Paste errors, screenshots, or screen recordings.
placeholder: Paste logs here or drag screenshots
validations:
required: false
-5
View File
@@ -1,5 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Questions & Discussion
url: https://github.com/zhom/donutbrowser/discussions
about: Ask questions or discuss ideas here instead of opening an issue.
@@ -1,30 +0,0 @@
name: Feature Request
description: Suggest a new feature
labels: ["enhancement"]
body:
- type: textarea
id: description
attributes:
label: What do you want?
placeholder: Describe the feature and why you need it.
validations:
required: true
- type: textarea
id: use-case
attributes:
label: Use case
placeholder: How would you use this feature? What problem does it solve?
validations:
required: true
- type: dropdown
id: priority
attributes:
label: How important is this to you?
options:
- Nice to have
- Would improve my workflow
- Critical for my use case
validations:
required: true
+46 -12
View File
@@ -1,20 +1,54 @@
## Which issue does this PR fix?
# ✨ Pull Request
<!-- Link the issue. #123 -->
## 📓 Referenced Issue
## How to test
<!-- Please link the related issue. Use # before the issue number and use the verbs 'fixes', 'resolves' to auto-link it, for eg, Fixes: #<issue-number> -->
<!-- Steps for the reviewer to verify your changes work -->
## ️ About the PR
## Checklist
<!-- Please provide a description of your solution if it is not clear in the related issue or if the PR has a breaking change. If there is an interesting topic to discuss or you have questions or there is an issue with Tauri, Rust, or another library that you have used. -->
- [ ] Read [CONTRIBUTING.md](https://github.com/zhom/donutbrowser/blob/main/CONTRIBUTING.md)
- [ ] Ran `pnpm format && pnpm lint && pnpm test` locally and it passes
- [ ] I tested the changes myself by running the app locally
- [ ] Updated translations in all locale files (if UI text changed)
## 🔄 Type of Change
## AI usage
<!-- Mark the relevant option with an "x". -->
- [ ] I used AI to help write this PR
- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
- [ ] ✨ New feature (non-breaking change which adds functionality)
- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] 📚 Documentation update
- [ ] 🧹 Code cleanup/refactoring
- [ ] ⚡ Performance improvement
<!-- If you checked the box above, briefly explain how AI was used (e.g. "generated the test", "wrote the initial implementation", "full PR"). -->
## 🖼️ Testing Scenarios / Screenshots
<!-- Please include screenshots or gif to showcase the final output. Also, try to explain the testing you did to validate your change. -->
## ✅ Checklist
<!-- Mark completed items with an "x". -->
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published
## 🧪 How Has This Been Tested?
<!-- Please describe the tests that you ran to verify your changes. -->
## 📱 Platform Testing
<!-- Which platforms have you tested on? -->
- [ ] macOS (Intel)
- [ ] macOS (Apple Silicon)
- [ ] Windows (if applicable)
- [ ] Linux (if applicable)
## 📋 Additional Notes
<!-- Any additional information that reviewers should know about this PR. -->
-33
View File
@@ -1,33 +0,0 @@
messages:
- role: system
content: |-
You are an expert technical writer tasked with generating comprehensive release notes for Donut Browser, a powerful anti-detect browser desktop app built with Tauri + Next.js that helps users manage multiple browser profiles with proxy support.
Guidelines:
- Use clear, user-friendly language
- Group related commits logically
- Omit minor commits like formatting, typos unless significant
- Focus on user-facing changes
- Use emojis sparingly and consistently
- Keep descriptions concise but informative
- If commits are unclear, infer the purpose from the context
- Only include sections that have relevant changes
- role: user
content: |-
Generate release notes for version {{version}} based on these commits:
{{commits}}
Use this format:
## What's New in {{version}}
[Brief 1-2 sentence overview]
### New Features
### Bug Fixes
### Improvements
### Documentation
### Dependencies
### Developer Experience
model: openai/gpt-4.1
@@ -1,23 +0,0 @@
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
+43 -8
View File
@@ -12,7 +12,7 @@ on:
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
security-events: write
packages: read
@@ -27,25 +27,60 @@ jobs:
build-mode: none
- language: javascript-typescript
build-mode: none
- language: rust
build-mode: none
# - language: rust
# build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
- name: Set up Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
with:
node-version-file: .node-version
cache: "pnpm"
- name: Setup Rust
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
with:
toolchain: stable
targets: x86_64-unknown-linux-gnu
- name: Install system dependencies (Rust only)
if: matrix.language == 'rust'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 #v2.8.0
with:
workdir: ./src-tauri
- name: Install dependencies from lockfile
run: pnpm install --frozen-lockfile
- name: Install rust dependencies
if: matrix.language == 'rust'
working-directory: ./src-tauri
run: |
cargo build
- name: Build nodecar sidecar
if: matrix.language == 'rust'
shell: bash
working-directory: ./nodecar
run: |
pnpm run build:linux-x64
- name: Copy nodecar binary to Tauri binaries
if: matrix.language == 'rust'
shell: bash
run: |
mkdir -p src-tauri/binaries
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu
- name: Initialize CodeQL
uses: github/codeql-action/init@b1e4dc3db58c9601794e22a9f6d28d45461b9dbf #v3.29.0
+1 -9
View File
@@ -1,12 +1,7 @@
name: Contributors
on:
push:
branches:
- main
release:
types:
- published
permissions:
contents: write
@@ -14,16 +9,13 @@ permissions:
jobs:
contrib-readme-job:
if: github.repository == 'zhom/donutbrowser'
runs-on: ubuntu-latest
name: Automatically update the contributors list in the README
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Contribute List
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
uses: akhilmhdh/contributors-readme-action@1ff4c56187458b34cd602aee93e897344ce34bfc #v2.3.10
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+17 -14
View File
@@ -12,14 +12,15 @@ permissions:
jobs:
security-scan:
name: Security Vulnerability Scan
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
if: ${{ github.actor == 'dependabot[bot]' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
with:
scan-args: |-
-r
--skip-git
--lockfile=pnpm-lock.yaml
--lockfile=src-tauri/Cargo.lock
--lockfile=nodecar/pnpm-lock.yaml
./
permissions:
security-events: write
@@ -28,7 +29,7 @@ jobs:
lint-js:
name: Lint JavaScript/TypeScript
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
if: ${{ github.actor == 'dependabot[bot]' }}
uses: ./.github/workflows/lint-js.yml
secrets: inherit
permissions:
@@ -36,7 +37,7 @@ jobs:
lint-rust:
name: Lint Rust
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
if: ${{ github.actor == 'dependabot[bot]' }}
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
permissions:
@@ -44,7 +45,6 @@ jobs:
codeql:
name: CodeQL
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: ./.github/workflows/codeql.yml
secrets: inherit
permissions:
@@ -55,7 +55,6 @@ jobs:
spellcheck:
name: Spell Check
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: ./.github/workflows/spellcheck.yml
secrets: inherit
permissions:
@@ -63,19 +62,23 @@ jobs:
dependabot-automerge:
name: Dependabot Automerge
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
if: ${{ github.actor == 'dependabot[bot]' }}
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
runs-on: ubuntu-latest
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 #v3.1.0
uses: dependabot/fetch-metadata@08eff52bf64351f401fb50d4972fa95b9f2c2d1b #v2.4.0
secrets: inherit
with:
compat-lookup: true
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Enable auto-merge for minor and patch updates
if: ${{ steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch' }}
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Auto-merge minor and patch updates
uses: ridedott/merge-me-action@338053c6f9b9311a6be80208f6f0723981e40627 #v2.10.122
secrets: inherit
with:
GITHUB_TOKEN: ${{ secrets.SECRET_DEPENDABOT_GITHUB_TOKEN }}
MERGE_METHOD: SQUASH
PRESET: DEPENDABOT_MINOR
MAXIMUM_RETRIES: 5
timeout-minutes: 10
-73
View File
@@ -1,73 +0,0 @@
name: Build and Push donut-sync Docker Image
on:
push:
branches: [main]
paths:
- "donut-sync/**"
workflow_call:
inputs:
tag:
description: "Docker tag (e.g., v1.0.0)"
required: true
type: string
workflow_dispatch:
inputs:
tag:
description: "Docker tag (e.g., v1.0.0, latest)"
required: true
default: "latest"
permissions:
contents: read
env:
REGISTRY: docker.io
IMAGE_NAME: donutbrowser/donut-sync
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
- name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee #v4.2.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Determine tags
id: tags
run: |
TAGS=""
INPUT_TAG="${{ inputs.tag }}"
if [ -n "$INPUT_TAG" ]; then
# Called from release workflow or manual dispatch
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${INPUT_TAG}"
TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
elif [ "${{ github.event_name }}" = "push" ]; then
# Push to main (nightly): tag with nightly and commit SHA
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly"
TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly-${SHORT_SHA}"
fi
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
echo "Tags: ${TAGS}"
- name: Build and push Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf #v7.2.0
with:
context: .
file: ./donut-sync/Dockerfile
push: true
tags: ${{ steps.tags.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
-57
View File
@@ -1,57 +0,0 @@
name: Flake Test
on:
pull_request:
paths:
- "flake.nix"
- "flake.lock"
- ".github/workflows/flake-test.yml"
push:
branches:
- main
paths:
- "flake.nix"
- "flake.lock"
- ".github/workflows/flake-test.yml"
workflow_dispatch:
permissions:
contents: read
jobs:
flake:
name: validate-flake
runs-on: ubuntu-22.04
timeout-minutes: 90
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Install Nix
uses: cachix/install-nix-action@a6f7623b2e2401f485f1eead77ced45bd99b09b0 #v31
with:
extra_nix_config: |
experimental-features = nix-command flakes
- name: Evaluate flake outputs
run: nix flake show --all-systems
- name: Check setup app is exposed
run: nix eval .#apps.x86_64-linux.setup.program --raw
- name: Run flake setup app
env:
CI: "true"
run: nix run .#setup
- name: Run flake info app
run: nix run .#info
# `nix flake show` above only evaluates the flake. This step actually
# compiles the app inside the Nix environment, which is what catches a
# missing build-time dependency — in particular libayatana-appindicator
# (required by libappindicator-sys for the Linux system tray). The build
# fails here if that dependency is dropped from the flake.
- name: Build the app via the flake
run: nix run .#build
+16
View File
@@ -0,0 +1,16 @@
name: Greetings
on: [pull_request_target, issues]
jobs:
greeting:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/first-interaction@34f15e814fe48ac9312ccf29db4e74fa767cbab7 #v1.3.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: "Thank you for your first issue ❤️ If it's a feature request, please make sure it's clear what you want, why you want it, and how important it is to you. If you posted a bug report, please make sure it includes as much detail as possible."
pr-message: "Welcome to the community and thank you for your first contribution ❤️ A human will review your PR shortly. Make sure that the pipelines are green, so that the PR is considered ready for a review and could be merged."
-133
View File
@@ -1,133 +0,0 @@
name: Issue Compliance Check
on:
issues:
types: [opened]
permissions:
contents: read
issues: write
env:
MODEL: z-ai/glm-5.1
jobs:
check-compliance:
# Maintainers' own issues are exempt — they open quick tracking issues
# without the template on purpose. Everyone else is checked.
if: >-
github.repository == 'zhom/donutbrowser' &&
github.event.issue.author_association != 'OWNER' &&
github.event.issue.author_association != 'MEMBER'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Gather context
env:
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
- name: Build prompt
run: |
cat > /tmp/system.txt <<'PROMPT'
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)
- Feature Request (description + verification checkbox)
- Question (free form)
## Compliance — flag NON-compliant ONLY when at least one of these is true
- The issue body is empty or contains only placeholder text from the template
- The issue is an obvious AI-generated wall of text with no real specifics
- A bug report has no reproduction information or no error description
- A feature request gives no use case at all
- The author left required fields empty (Operating System, Donut Browser version, Which browser is affected, Steps to reproduce on bug reports)
Do NOT flag for missing optional fields, missing screenshots, short titles, or stylistic issues. Be conservative — a non-compliant verdict closes the issue, so only flag a genuine template violation.
## Output schema
{
"is_compliant": true | false,
"non_compliance_reasons": ["short bullet", ...]
}
If there is nothing to flag, return:
{"is_compliant": true, "non_compliance_reasons": []}
PROMPT
- name: Call OpenRouter
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
run: |
PAYLOAD=$(jq -n \
--arg model "$MODEL" \
--rawfile system_prompt /tmp/system.txt \
--rawfile title /tmp/issue-title.txt \
--rawfile body /tmp/issue-body.txt \
'{
model: $model,
messages: [
{ role: "system", content: $system_prompt },
{ role: "user",
content: ("New issue title: " + $title + "\n\nNew issue body:\n" + $body) }
],
response_format: { type: "json_object" }
}')
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/raw.txt
# Strip accidental markdown fences and parse. On parse failure, fall back
# to a compliant result so a flaky model never closes a legitimate issue.
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
echo "::warning::Model returned non-JSON; treating as compliant"
cat /tmp/raw.txt
echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json
fi
echo "Result:"
cat /tmp/result.json
- name: Build comment
id: build
run: |
python3 - <<'EOF'
import json, os
r = json.load(open('/tmp/result.json'))
compliant = bool(r.get('is_compliant', True))
reasons = r.get('non_compliance_reasons') or []
parts = []
if not compliant:
parts.append("This issue was automatically closed because it doesn't follow our [issue templates](../issues/new/choose).")
parts.append('')
parts.append('**What was missing:**')
for reason in reasons:
parts.append(f'- {reason}')
parts.append('')
parts.append('If this is a real bug or feature request, please open a new issue using the **Bug Report** or **Feature Request** template and fill in the required fields. Issues that ignore the template are not triaged.')
comment = '\n'.join(parts).strip()
open('/tmp/comment.md', 'w').write(comment)
with open(os.environ['GITHUB_OUTPUT'], 'a') as fh:
fh.write(f'non_compliant={"true" if not compliant else "false"}\n')
EOF
- name: Comment and close non-compliant issue
if: steps.build.outputs.non_compliant == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
gh issue close "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --reason "not planned"
+146 -601
View File
@@ -1,628 +1,173 @@
name: Issue & PR Automation
name: Issue Validation
on:
issues:
types: [opened]
pull_request_target:
types: [opened]
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
permissions:
contents: read
issues: write
pull-requests: write
id-token: write
env:
# Single source of truth for the model used by both triage and composer.
TRIAGE_MODEL: z-ai/glm-5.1
COMPOSER_MODEL: z-ai/glm-5.1
models: read
jobs:
analyze-issue:
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'issues'
validate-issue:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Check if first-time contributor
id: check-first-time
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
- name: Get issue templates
id: get-templates
run: |
ISSUE_COUNT=$(gh api "/repos/${{ github.repository }}/issues?state=all&creator=$ISSUE_AUTHOR&per_page=100" \
--jq "[.[] | select(.number != ${{ github.event.issue.number }}) ] | length" \
|| echo "0")
if [ "$ISSUE_COUNT" = "0" ]; then
echo "is_first_time=true" >> $GITHUB_OUTPUT
else
echo "is_first_time=false" >> $GITHUB_OUTPUT
# Read the issue templates
if [ -f ".github/ISSUE_TEMPLATE/01-bug-report.md" ]; then
echo "bug-template-exists=true" >> $GITHUB_OUTPUT
fi
- name: Parse issue template fields
env:
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
node <<'EOF'
const fs = require('node:fs');
const body = process.env.ISSUE_BODY || '';
// GitHub issue templates render fields as `### Heading\nValue` blocks.
// Split on `###` at line start to recover them.
const fields = {};
const sections = body.split(/^###\s+/m);
for (const section of sections.slice(1)) {
const nl = section.indexOf('\n');
if (nl < 0) continue;
const heading = section.slice(0, nl).trim();
const value = section.slice(nl + 1).trim();
fields[heading] = value === '_No response_' ? '' : value;
}
fs.writeFileSync('/tmp/issue-fields.json', JSON.stringify(fields, null, 2));
// Convenience extractions for the prompt — empty string if missing.
const get = (k) => fields[k] || '';
fs.writeFileSync('/tmp/issue-os.txt', get('Operating System'));
fs.writeFileSync('/tmp/issue-version.txt', get('Donut Browser version'));
fs.writeFileSync('/tmp/issue-browser.txt', get('Which browser is affected?'));
fs.writeFileSync('/tmp/issue-repro.txt', get('Steps to reproduce'));
fs.writeFileSync('/tmp/issue-logs.txt', get('Error logs or screenshots'));
fs.writeFileSync('/tmp/issue-what.txt', get('What happened?') || get('What do you want?'));
EOF
echo "Parsed fields:"
cat /tmp/issue-fields.json
if [ -f ".github/ISSUE_TEMPLATE/02-feature-request.md" ]; then
echo "feature-template-exists=true" >> $GITHUB_OUTPUT
fi
- name: Build repo context
- name: Create issue analysis prompt
id: create-prompt
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
ISSUE_LABELS: ${{ join(github.event.issue.labels.*.name, ', ') }}
run: |
cp CLAUDE.md /tmp/repo-context.txt
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
cat > issue_analysis.txt << EOF
## Issue Content to Analyze:
# List all source files for the AI to choose from
find . -type f \( -name "*.rs" -o -name "*.ts" -o -name "*.tsx" \) \
! -path "*/node_modules/*" ! -path "*/target/*" ! -path "*/.next/*" ! -path "*/dist/*" \
! -path "*/.git/*" ! -path "*/gen/*" ! -path "*/data/*" \
| sed 's|^\./||' | sort > /tmp/all-source-files.txt
**Title:** $ISSUE_TITLE
- name: Write shared knowledge files (scope + pricing)
run: |
cat > /tmp/scope-and-pricing.md <<'EOF'
# PROJECT SCOPE
**Body:**
$ISSUE_BODY
- **Donut Browser** — this repo. A Tauri desktop launcher (Rust + Next.js) that
downloads, manages, and launches anti-detect browser profiles. In-scope for bug
reports about profile management, downloads, sync, proxy, VPN, the launcher UI,
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, 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 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
supported. Feature requests asking for them are out of scope.
# PAID vs FREE FEATURES
Source: donutbrowser.com pricing tiers (verbatim from translations).
## Free (no account required)
- Unlimited local profiles
- Chromium (Wayfern) and Firefox (Camoufox) browser engines
- Proxy support (HTTP/SOCKS5)
- VPN support (WireGuard)
- Profile Management API & MCP (list / create / launch / kill / config)
- Cookie & Extension Management
- Set as default browser
- **Profile sync IS FREE if the user self-hosts the `donut-sync` server**
## Pro ($16/mo) — adds:
- Browser Manipulation API & MCP (`type_text`, `click_element`,
`evaluate_javascript`, `screenshot`, `navigate`, etc.)
- Cross-OS fingerprinting (e.g. macOS user appearing as Windows)
- Profile Synchronizer for Wayfern
- 20 cloud profile backup (cloud sync via donutbrowser.com)
- Commercial use license
## Team ($80/mo) — adds:
- 100 cloud profile sync
- Team collaboration, profile sharing, unlimited seats
# ANTI-PATTERNS
- **Regression**: user explicitly mentions a previous version that worked
differently ("worked in 0.21", "went from 2 to 8 false positives"). Do NOT
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. 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.
- **AI-generated / template-violating report**: report doesn't follow the
template, may cite "official documentation" via context7, deepwiki, or any
non-`donutbrowser.com` / non-`github.com/zhom` URL. The only authoritative
sources are this GitHub repo and donutbrowser.com.
- **Speculation about internals**: never write a "Possible cause" / "Likely
cause" / "Root cause" section. Never cite internal file paths or line
numbers. Never speculate about how subscription / paid-plan checks work.
# OS-SPECIFIC LOG PATHS (use ONLY the one matching the user's OS)
# Easiest path for the user: Donut → Settings → Advanced → Copy logs
# (puts the latest rotated log on the clipboard). If they prefer to
# attach files directly, the active log is `DonutBrowser.log`; older
# rotated copies sit next to it (`DonutBrowser.log.YYYY-MM-DD-…`).
- macOS: `~/Library/Logs/com.donutbrowser/DonutBrowser.log`
- Linux: `~/.local/share/com.donutbrowser/logs/DonutBrowser.log`
- Windows: `%LOCALAPPDATA%\com.donutbrowser\logs\DonutBrowser.log`
# KNOWN ERROR SIGNATURES (truth, not guesses — match these
# verbatim before suggesting anything else)
- **`CDP not ready after N attempts on port X: HTTP 5xx ...`** —
an HTTP 5xx (503 / 502) response from a freshly-launched
browser's `/json/version` endpoint always means *something on
the loopback path is intercepting the connection*: a firewall,
an antivirus web-shield (Kaspersky, Bitdefender, ESET, Avast /
AVG, Yandex Protect on Windows; Little Snitch, LuLu on macOS),
a VPN client that hijacks 127.0.0.1, or a corporate MDM /
proxy (Zscaler, Cisco AnyConnect, Netskope). Chrome's
DevTools endpoint never returns 5xx itself — only synthetic
responses from interception layers do. **Do NOT speculate
about Gatekeeper, first-launch verification, code signing, or
quarantine** — none of those cause a 5xx response, and
Gatekeeper never delays a launch long enough to surface as
"120 attempts". Lead with: which AV / web-shield / firewall /
VPN / MDM is installed, and ask the user to try with the AV's
web-shield component temporarily disabled (not the whole AV).
**Labels:** $ISSUE_LABELS
EOF
- name: Build triage system prompt
run: |
# The static system prompt has apostrophes ("doesn't", "official docs"
# etc.) that collide with shell single-quoting if embedded directly in
# the jq filter. Build the full prompt to a file instead, then load it
# via --rawfile in the next step.
{
cat <<'TRIAGE_HEAD'
You are a triage classifier for the Donut Browser GitHub repo. Classify the issue and pick at most 20 source files for a composer to read.
TRIAGE_HEAD
cat /tmp/scope-and-pricing.md
printf '\n\n# REPO GUIDELINES\n'
cat /tmp/repo-context.txt
cat <<'TRIAGE_TAIL'
# OUTPUT
Return ONLY valid JSON. No preamble, no code fences. Schema:
{
"language": "en" or ISO 639-1 code,
"classification": one of ["bug-in-scope", "bug-upstream-camoufox", "bug-template-violation", "feature-request", "fork-request", "regression", "ai-generated-junk", "question", "other"],
"operating_system": "macos" | "windows" | "linux" | "unknown",
"is_paid_feature": true | false,
"user_followed_template": true | false,
"regression_signal": quoted user snippet or null,
"user_cited_external_docs": URL string or null,
"files_to_read": array of at most 20 file paths from the list,
"notes": one short sentence describing what you observed
}
Classification guidance:
- "bug-upstream-camoufox": Camoufox-internal behavior (rendering, dropdowns, JS, fingerprint impl). NOT how Donut launches it.
- "bug-template-violation": missing or filled-in nonsense for required template fields.
- "ai-generated-junk": cites fabricated "official docs" (context7, deepwiki, non-donutbrowser URLs) or has the polished AI-spam shape (long, structured, fabricated certainty).
- "fork-request": asks for support of CloverLabsAI/VulpineOS/etc. forks.
- "regression": user names a prior version that worked.
File selection: pick files that an experienced reviewer would actually look at to act on this issue. If the issue is upstream-Camoufox, fork-request, or junk, set files_to_read to []. Otherwise pick concrete files relevant to the symptoms.
TRIAGE_TAIL
} > /tmp/triage-system.txt
wc -c /tmp/triage-system.txt
- name: Stage 1 — Triage and file selection
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
run: |
# The triage call returns ONLY JSON. It classifies the issue and picks a
# short list of source files for the composer to read.
PAYLOAD=$(jq -n \
--arg model "$TRIAGE_MODEL" \
--rawfile system_prompt /tmp/triage-system.txt \
--rawfile title /tmp/issue-title.txt \
--rawfile body /tmp/issue-body.txt \
--rawfile fields /tmp/issue-fields.json \
--rawfile files /tmp/all-source-files.txt \
'{
model: $model,
messages: [
{ role: "system", content: $system_prompt },
{ role: "user",
content: ("Issue title: " + $title + "\n\nBody:\n" + $body + "\n\nParsed template fields:\n" + $fields + "\n\nAll source files:\n" + $files) }
]
}')
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/triage-raw.txt
# Strip ```json fences if the model couldn't help itself.
sed -E 's/^```(json)?$//; s/```$//' /tmp/triage-raw.txt > /tmp/triage.json
# Validate; if the model returned junk, fall back to a minimal stub so the
# composer still gets called and produces SOMETHING.
if ! jq -e . /tmp/triage.json >/dev/null 2>&1; then
echo "::warning::Triage returned non-JSON; using fallback classification"
cat /tmp/triage-raw.txt
jq -n '{
language: "en",
classification: "bug-in-scope",
operating_system: "unknown",
is_paid_feature: false,
user_followed_template: true,
regression_signal: null,
user_cited_external_docs: null,
files_to_read: [],
notes: "triage call failed; defaulting"
}' > /tmp/triage.json
fi
echo "Triage result:"
cat /tmp/triage.json
- name: Read files chosen by triage
run: |
: > /tmp/file-context.txt
# files_to_read may be empty (e.g. upstream Camoufox) — that's fine.
jq -r '.files_to_read[]? // empty' /tmp/triage.json | while IFS= read -r filepath; do
filepath=$(echo "$filepath" | xargs)
[ -z "$filepath" ] && continue
# Reject paths that escape the repo or look fishy
case "$filepath" in
/*|*..*|*$'\n'*) continue ;;
esac
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
echo "=== $filepath ===" >> /tmp/file-context.txt
cat "$filepath" >> /tmp/file-context.txt
echo "" >> /tmp/file-context.txt
fi
done
# Cap total context at 100 KB to keep token cost bounded.
head -c 100000 /tmp/file-context.txt > /tmp/file-context.capped.txt
mv /tmp/file-context.capped.txt /tmp/file-context.txt
wc -c /tmp/file-context.txt
- name: Build composer system prompt
run: |
# Same reason as the triage prompt: lots of apostrophes, no shell-quoting
# gymnastics. Build it to a file, load via --rawfile.
{
cat <<'COMPOSER_HEAD'
You are a triage assistant for Donut Browser. You compose ONE short GitHub comment in response to a freshly opened issue. The triage step has already classified the issue — use the classification verbatim, do not re-litigate it.
COMPOSER_HEAD
cat /tmp/scope-and-pricing.md
printf '\n\n# REPO GUIDELINES\n'
cat /tmp/repo-context.txt
cat <<'COMPOSER_TAIL'
# RULES — STRICT
## Output shape
- One sentence acknowledging the report.
- Then **Missing information** — only if there is anything actually missing. Skip this section if the user already provided OS, version, browser, repro steps, and any logs the situation calls for.
- Maximum 15 lines.
- No labels, no `Label:` line, no markdown headings other than `**Missing information**`.
- No closing pleasantries ("please let me know", "happy to help", etc.).
## Forbidden — never do these
- NEVER include a `Possible cause` / `Likely cause` / `Root cause` / `Probably caused by` section. You do not have enough information; speculation is always wrong here.
- NEVER cite internal file paths or line numbers in the comment. Internal references rot and confuse non-developers.
- NEVER reference how subscription / paid-plan checks work internally. You do not know whether the user's claim is correct.
- NEVER call a report "well-documented", "well-structured", "clear", "thorough", "reasonable", "well-thought-out", or any similar evaluation. You are triage, not peer review.
- NEVER list more than one OS log path. Use ONLY the path matching the user's reported OS. If OS is unknown, ask for it instead of listing all three.
- NEVER validate a feature request as "a clear enhancement" / "a reasonable request" / similar. Acknowledge neutrally and ask only the missing info (use case, urgency).
- NEVER call a report "a known and expected behavior" or "a false positive" if the user mentions a regression. The triage tells you when this applies.
## Classification handling
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 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.
- `regression`: do NOT call known/expected. Ask which exact previous version was the last working one, what changed in the user's environment between then and now, and the specific delta in symptoms.
- `question`: answer briefly if obvious from repo guidelines / pricing; otherwise ask for clarification.
## Paid-feature awareness
If `triage.is_paid_feature` is true, factor the pricing tiers into your reply. For Pro-only features (browser manipulation API/MCP, cross-OS fingerprinting, Wayfern Profile Synchronizer, cloud sync), confirm the user is logged in with an active subscription before asking for logs. If the issue is about cloud sync, mention that self-hosting `donut-sync` makes sync free and is a viable alternative.
## Language
If the issue body is not in English, write the comment in English (the maintainer reads English). The FIRST line must politely ask the user to communicate in English so the maintainer can help. Then continue with the normal triage response, in English.
## OS-specific log paths
Recommend Settings → Advanced → Copy logs first — it bundles the
latest rotated log onto the clipboard without the user hunting for
a directory. If they want to attach files directly, point at the
path that matches `triage.operating_system`. The active log is
always `DonutBrowser.log`; rotated copies sit next to it.
- macos: `~/Library/Logs/com.donutbrowser/DonutBrowser.log`
- linux: `~/.local/share/com.donutbrowser/logs/DonutBrowser.log`
- windows: `%LOCALAPPDATA%\com.donutbrowser\logs\DonutBrowser.log` (PowerShell: `Get-Content $env:LOCALAPPDATA\com.donutbrowser\logs\DonutBrowser.log -Tail 200`)
- unknown: ask the user to share their OS first.
## Known error signatures (apply BEFORE asking generic questions)
If the issue body contains any of these, lead with the matching
response — do NOT speculate about other causes:
- `CDP not ready after N attempts on port X: HTTP 5xx ...` —
this is loopback interception by a firewall / antivirus
web-shield / VPN / MDM. Lead with that question (specifically:
Kaspersky, Bitdefender, ESET, Avast/AVG, Yandex Protect on
Windows; Little Snitch, LuLu, corporate MDM on macOS; any
VPN). Suggest temporarily disabling the AV's web-shield
component (NOT the whole AV) and retrying. Do NOT mention
Gatekeeper, first-launch verification, code signing, or
quarantine — none of those cause an HTTP 5xx response, and
Gatekeeper never delays a launch long enough to produce a
"120 attempts" failure.
COMPOSER_TAIL
} > /tmp/composer-system.txt
wc -c /tmp/composer-system.txt
- name: Stage 2 — Compose response
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
IS_FIRST_TIME: ${{ steps.check-first-time.outputs.is_first_time }}
run: |
GREETING=""
if [ "$IS_FIRST_TIME" = "true" ]; then
# Use printf with %s so the apostrophe inside the string never has to
# cross a shell single-quote boundary.
printf '%s' 'This is the first issue from this user — start the comment with "Thanks for opening your first issue!" on its own line.' > /tmp/greeting.txt
else
: > /tmp/greeting.txt
fi
printf '%s' "$ISSUE_AUTHOR" > /tmp/issue-author.txt
PAYLOAD=$(jq -n \
--arg model "$COMPOSER_MODEL" \
--rawfile system_prompt /tmp/composer-system.txt \
--rawfile title /tmp/issue-title.txt \
--rawfile body /tmp/issue-body.txt \
--rawfile author /tmp/issue-author.txt \
--rawfile fields /tmp/issue-fields.json \
--rawfile triage /tmp/triage.json \
--rawfile greeting /tmp/greeting.txt \
--rawfile files /tmp/file-context.txt \
'{
model: $model,
messages: [
{ role: "system", content: $system_prompt },
{ role: "user",
content: ((if ($greeting | length) > 0 then $greeting + "\n\n" else "" end)
+ "Title: " + $title
+ "\nAuthor: " + $author
+ "\n\n## Triage result\n" + $triage
+ "\n\n## Parsed template fields\n" + $fields
+ "\n\n## Raw issue body\n" + $body
+ "\n\n## Source files (selected by triage)\n" + $files) }
]
}')
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/ai-comment.txt
if [ ! -s /tmp/ai-comment.txt ]; then
echo "::error::Composer returned empty response"
echo "Raw response:"
echo "$RESPONSE"
exit 1
fi
- name: Strip forbidden sections (defense in depth)
run: |
# Even with explicit prompt rules, LLMs sometimes still emit "Possible cause"
# and friends. Strip any such heading + its block. Also drop any stray
# `Label:` lines from earlier prompt iterations.
python3 - <<'EOF'
import re
path = '/tmp/ai-comment.txt'
text = open(path).read()
# Drop forbidden section headers and everything until a blank line or another header.
forbidden = re.compile(
r'^\s*\**\s*(?:possible|likely|root|probable)\s+cause\b.*?(?=^\s*$|\n##|\n\*\*[A-Z]|\Z)',
re.IGNORECASE | re.MULTILINE | re.DOTALL,
)
text = forbidden.sub('', text)
# Drop stale Label: lines (we don't label anymore).
text = re.sub(r'^\s*Label:\s*.*$', '', text, flags=re.MULTILINE)
# Collapse 3+ blank lines.
text = re.sub(r'\n{3,}', '\n\n', text).strip() + '\n'
open(path, 'w').write(text)
EOF
- name: Post comment (no labeling)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
analyze-pr:
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Check if first-time contributor
id: check-first-time
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
PR_COUNT=$(gh api "/repos/${{ github.repository }}/pulls?state=all&per_page=100" \
--jq "[.[] | select(.user.login == \"$PR_AUTHOR\" and .number != ${{ github.event.pull_request.number }})] | length" \
|| echo "0")
if [ "$PR_COUNT" = "0" ]; then
echo "is_first_time=true" >> $GITHUB_OUTPUT
else
echo "is_first_time=false" >> $GITHUB_OUTPUT
fi
- name: Gather PR context
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" \
--jq '.[] | "- \(.filename) (\(.status)) +\(.additions)/-\(.deletions)"' \
> /tmp/pr-files.txt
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \
--header "Accept: application/vnd.github.diff" \
> /tmp/pr-diff-full.txt 2>/dev/null || true
head -c 20000 /tmp/pr-diff-full.txt > /tmp/pr-diff.txt
cat CONTRIBUTING.md > /tmp/contributing.txt 2>/dev/null || echo "Not found" > /tmp/contributing.txt
head -50 README.md > /tmp/readme.txt 2>/dev/null || echo "Not found" > /tmp/readme.txt
cp CLAUDE.md /tmp/repo-context.txt
: > /tmp/related-file-contents.txt
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' | while IFS= read -r filepath; do
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
echo "=== $filepath (full file) ===" >> /tmp/related-file-contents.txt
cat "$filepath" >> /tmp/related-file-contents.txt
echo "" >> /tmp/related-file-contents.txt
fi
done
head -c 100000 /tmp/related-file-contents.txt > /tmp/pr-file-context.txt
- name: Analyze PR with AI
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_BASE: ${{ github.event.pull_request.base.ref }}
PR_HEAD: ${{ github.event.pull_request.head.ref }}
IS_FIRST_TIME: ${{ steps.check-first-time.outputs.is_first_time }}
run: |
GREETING=""
if [ "$IS_FIRST_TIME" = "true" ]; then
GREETING='This is a first-time contributor. Start your comment with: "Thanks for your first PR!"'
fi
printf '%s' "$PR_TITLE" > /tmp/pr-title.txt
printf '%s' "${PR_BODY:-}" > /tmp/pr-body.txt
printf '%s' "$PR_AUTHOR" > /tmp/pr-author.txt
printf '%s' "$PR_BASE" > /tmp/pr-base.txt
printf '%s' "$PR_HEAD" > /tmp/pr-head.txt
printf '%s' "$GREETING" > /tmp/greeting.txt
PAYLOAD=$(jq -n \
--arg model "$COMPOSER_MODEL" \
--rawfile title /tmp/pr-title.txt \
--rawfile body /tmp/pr-body.txt \
--rawfile author /tmp/pr-author.txt \
--rawfile base /tmp/pr-base.txt \
--rawfile head /tmp/pr-head.txt \
--rawfile files /tmp/pr-files.txt \
--rawfile diff /tmp/pr-diff.txt \
--rawfile greeting /tmp/greeting.txt \
--rawfile repo_context /tmp/repo-context.txt \
--rawfile contributing /tmp/contributing.txt \
--rawfile file_context /tmp/pr-file-context.txt \
'{
model: $model,
messages: [
{
role: "system",
content: ("You are a code review bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nContributing guidelines:\n" + $contributing + "\n\nYou have access to the full changed files and the diff. Use them to give a substantive review.\n\nReview this PR and produce a single comment. Format:\n\n1. One sentence summarizing what this PR does and whether the approach is sound.\n2. **Code review** - Specific observations about the actual code changes. Mention file names and what you see in the diff. Look for:\n - Bugs or logic errors in the changed code\n - Security issues (SQL injection, path traversal, XSS, command injection)\n - Missing error handling or edge cases\n - Breaking changes to existing APIs or behavior\n - If UI text was added/changed, check if all 7 translation files (en, es, fr, ja, pt, ru, zh) in src/i18n/locales/ were updated\n - If Tauri commands were added/removed, the unused-commands test in lib.rs needs updating\n3. **Suggestions** - Concrete improvements if any. Skip if the PR looks good.\n\nRules:\n- Be substantive. Review the actual diff, not just the description.\n- Do NOT nitpick formatting or style — the project has automated linting (biome + clippy + rustfmt).\n- Do NOT just summarize the PR description back to the user — they wrote it, they know what it says.\n- If the PR is good, say so briefly.\n- Never exceed 20 lines.")
},
{
role: "user",
content: (
(if ($greeting | length) > 0 then $greeting + "\n\n" else "" end) +
"Review this PR:\n\nTitle: " + $title +
"\nAuthor: " + $author +
"\nBase: " + $base + " <- Head: " + $head +
"\n\nDescription:\n" + $body +
"\n\nChanged files:\n" + $files +
"\n\nDiff:\n" + $diff +
"\n\nFull file contents:\n" + $file_context
)
}
]
}')
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/ai-comment.txt
if [ ! -s /tmp/ai-comment.txt ]; then
echo "::error::AI response was empty"
echo "Raw response:"
echo "$RESPONSE"
exit 1
fi
- name: Post comment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
opencode-command:
if: |
github.repository == 'zhom/donutbrowser' &&
(github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
(contains(github.event.comment.body, ' /oc') ||
startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') ||
startsWith(github.event.comment.body, '/opencode'))
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Run opencode
uses: anomalyco/opencode/github@385cb694419f98103af0e8fc6187ddcbcbb6eecb #v1.15.13
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Validate issue with AI
id: validate
uses: actions/ai-inference@d645f067d89ee1d5d736a5990e327e504d1c5a4a # v1.1.0
with:
model: zai-coding-plan/glm-4.7
prompt-file: issue_analysis.txt
system-prompt: |
You are an issue validation assistant for Donut Browser, an browser orchestrator.
Analyze the provided issue content and determine if it contains sufficient information based on these requirements:
**For Bug Reports, the issue should include:**
1. Clear description of the problem
2. Steps to reproduce the issue (numbered list preferred)
3. Expected vs actual behavior
4. Environment information (OS, browser version, etc.)
5. Error messages, stack traces, or screenshots if applicable
**For Feature Requests, the issue should include:**
1. Clear description of the requested feature
2. Use case or problem it solves
3. Proposed solution or how it should work
4. Priority level or importance
**General Requirements for all issues:**
1. Descriptive title
2. Sufficient detail to understand and act upon
3. Professional tone and clear communication
Respond in JSON format with the following structure:
```json
{
"is_valid": true|false,
"issue_type": "bug_report"|"feature_request"|"other",
"missing_info": [
"List of missing required information"
],
"suggestions": [
"Specific suggestions for improvement"
],
"overall_assessment": "Brief assessment of the issue quality"
}
```
Be constructive and helpful in your feedback. If the issue is incomplete, provide specific guidance on what's needed.
model: gpt-4o
- name: Parse validation result and take action
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Get the AI response
VALIDATION_RESULT='${{ steps.validate.outputs.response }}'
# Extract JSON from the response (handle potential markdown formatting)
JSON_RESULT=$(echo "$VALIDATION_RESULT" | sed -n '/```json/,/```/p' | sed '1d;$d' || echo "$VALIDATION_RESULT")
# Parse JSON fields
IS_VALID=$(echo "$JSON_RESULT" | jq -r '.is_valid // false')
ISSUE_TYPE=$(echo "$JSON_RESULT" | jq -r '.issue_type // "other"')
MISSING_INFO=$(echo "$JSON_RESULT" | jq -r '.missing_info[]? // empty' | sed 's/^/- /')
SUGGESTIONS=$(echo "$JSON_RESULT" | jq -r '.suggestions[]? // empty' | sed 's/^/- /')
ASSESSMENT=$(echo "$JSON_RESULT" | jq -r '.overall_assessment // "No assessment provided"')
echo "Issue validation result: $IS_VALID"
echo "Issue type: $ISSUE_TYPE"
if [ "$IS_VALID" = "false" ]; then
# Create a comment asking for more information
cat > comment.md << EOF
## 🤖 Issue Validation
Thank you for submitting this issue! However, it appears that some required information might be missing to help us better understand and address your concern.
**Issue Type Detected:** \`$ISSUE_TYPE\`
**Assessment:** $ASSESSMENT
### 📋 Missing Information:
$MISSING_INFO
### 💡 Suggestions for Improvement:
$SUGGESTIONS
### 📝 How to Provide Additional Information:
Please edit your original issue description to include the missing information. Here are our issue templates for reference:
- **Bug Report Template:** [View Template](.github/ISSUE_TEMPLATE/01-bug-report.md)
- **Feature Request Template:** [View Template](.github/ISSUE_TEMPLATE/02-feature-request.md)
### 🔧 Quick Tips:
- For **bug reports**: Include step-by-step reproduction instructions, your environment details, and any error messages
- For **feature requests**: Describe the use case, expected behavior, and why this feature would be valuable
- Add **screenshots** or **logs** when applicable
Once you've updated the issue with the missing information, feel free to remove this comment or reply to let us know you've made the updates.
---
*This validation was performed automatically to ensure we have all the information needed to help you effectively.*
EOF
# Post the comment
gh issue comment ${{ github.event.issue.number }} --body-file comment.md
# Add a label to indicate validation needed
gh issue edit ${{ github.event.issue.number }} --add-label "needs-info"
echo "✅ Validation comment posted and 'needs-info' label added"
else
echo "✅ Issue contains sufficient information"
# Add appropriate labels based on issue type
case "$ISSUE_TYPE" in
"bug_report")
gh issue edit ${{ github.event.issue.number }} --add-label "bug"
;;
"feature_request")
gh issue edit ${{ github.event.issue.number }} --add-label "enhancement"
;;
esac
fi
- name: Cleanup
run: |
rm -f issue_analysis.txt comment.md
+3 -5
View File
@@ -34,15 +34,13 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
- name: Set up Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
with:
node-version-file: .node-version
cache: "pnpm"
+31 -33
View File
@@ -12,6 +12,7 @@ on:
pull_request:
paths-ignore:
- "src/**"
- "nodecar/**"
- "package.json"
- "pnpm-lock.yaml"
- "yarn.lock"
@@ -29,9 +30,9 @@ permissions:
jobs:
build:
strategy:
fail-fast: false
fail-fast: true
matrix:
os: [macos-latest, ubuntu-22.04, windows-latest]
os: [macos-latest]
runs-on: ${{ matrix.os }}
@@ -41,21 +42,19 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Set up pnpm package manager
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
- name: Set up Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
with:
node-version-file: .node-version
cache: "pnpm"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
with:
toolchain: stable
components: rustfmt, clippy
@@ -64,43 +63,41 @@ jobs:
run: cargo install cargo-audit
- name: Install dependencies (Ubuntu only)
if: matrix.os == 'ubuntu-22.04'
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev openvpn
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Build frontend
run: pnpm next build
- name: Get host target
id: host_target
- name: Build nodecar binary
shell: bash
working-directory: ./nodecar
run: |
HOST_TARGET=$(rustc -vV | sed -n 's|host: ||p')
echo "target=${HOST_TARGET}" >> $GITHUB_OUTPUT
echo "Host target: ${HOST_TARGET}"
if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then
pnpm run build:linux-x64
elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then
pnpm run build:mac-aarch64
elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then
pnpm run build:win-x64
fi
- name: Build sidecar binaries
shell: bash
working-directory: ./src-tauri
run: |
cargo build --bin donut-proxy --release
- name: Copy sidecar binaries to Tauri binaries
- name: Copy nodecar binary to Tauri binaries
shell: bash
run: |
mkdir -p src-tauri/binaries
HOST_TARGET="${{ steps.host_target.outputs.target }}"
if [[ "$HOST_TARGET" == *"windows"* ]]; then
cp src-tauri/target/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${HOST_TARGET}.exe
else
cp src-tauri/target/release/donut-proxy src-tauri/binaries/donut-proxy-${HOST_TARGET}
chmod +x src-tauri/binaries/donut-proxy-${HOST_TARGET}
if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu
elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-aarch64-apple-darwin
elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then
cp nodecar/dist/nodecar.exe src-tauri/binaries/nodecar-x86_64-pc-windows-msvc.exe
fi
- name: Create empty 'dist' directory
run: mkdir dist
- name: Run rustfmt check
run: cargo fmt --all -- --check
working-directory: src-tauri
@@ -109,8 +106,9 @@ jobs:
run: cargo clippy --all-targets --all-features -- -D warnings -D clippy::all
working-directory: src-tauri
- name: Run test suite
run: pnpm test
- name: Run Rust unit tests
run: cargo test
working-directory: src-tauri
- name: Run cargo audit security check
run: cargo audit
-215
View File
@@ -1,215 +0,0 @@
name: Notify Telegram
# tauri-action creates the release with the default GITHUB_TOKEN, and GitHub
# Actions deliberately suppresses `release: published` events for releases
# made by GITHUB_TOKEN (to prevent recursive workflow chains). So we can't
# listen for `release: published` — it will never fire on stable releases.
#
# Instead, chain off the Release workflow via `workflow_run`, the same way
# `publish-repos.yml` does. `workflow_dispatch` is kept so a missed
# announcement can be replayed by hand.
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag to announce (e.g. v0.23.0). Leave empty for latest stable."
required: false
type: string
workflow_run:
workflows: ["Release"]
types:
- completed
permissions:
contents: read
models: read
jobs:
notify:
if: >
github.repository == 'zhom/donutbrowser' &&
(github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: main
fetch-depth: 0
- name: Resolve release tag
id: tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_TAG: ${{ inputs.tag }}
# `head_branch` of a workflow_run trigger is attacker-influenceable
# (anyone with push to a tag can choose its name), so we pass it via
# env and validate before use rather than splicing it into the
# shell script literally. See CodeQL actions/code-injection.
EVENT_NAME: ${{ github.event_name }}
WORKFLOW_RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
REPO: ${{ github.repository }}
run: |
if [[ -n "${INPUT_TAG:-}" ]]; then
TAG="${INPUT_TAG}"
elif [[ "${EVENT_NAME}" == "workflow_run" ]]; then
# The Release workflow runs on `push: tags: v*` so head_branch
# of the triggering run is the tag name. Reject anything that
# isn't a plain tag-shaped string to keep this resistant to
# shell metacharacters injected via a crafted ref name.
if [[ ! "${WORKFLOW_RUN_HEAD_BRANCH}" =~ ^[A-Za-z0-9._/-]+$ ]]; then
echo "::error::Refusing tag with unexpected characters: ${WORKFLOW_RUN_HEAD_BRANCH}"
exit 1
fi
TAG="${WORKFLOW_RUN_HEAD_BRANCH}"
else
TAG=$(gh release view --repo "${REPO}" --json tagName -q .tagName)
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "Resolved tag: ${TAG}"
- name: Skip pre-releases / missing releases
id: gate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
# Tag like `nightly-…` or `nightly` is never an announceable
# stable release. Short-circuit before hitting the API.
if [[ "${TAG}" == nightly* ]]; then
echo "Tag '${TAG}' is a rolling/nightly build, skipping Telegram post."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Only stable semver tags vX.Y.Z are eligible. Reject anything
# with a pre-release suffix (`-rc1`, `-beta`, etc.).
if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Tag '${TAG}' is not a stable semver tag, skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Confirm the release exists and isn't marked prerelease in the
# GitHub UI — guards against someone manually flipping the flag.
RELEASE_JSON=$(gh release view "${TAG}" --repo "${{ github.repository }}" --json isPrerelease,tagName 2>/dev/null || echo "")
if [[ -z "${RELEASE_JSON}" ]]; then
echo "Release ${TAG} not found via gh — skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
IS_PRE=$(jq -r .isPrerelease <<< "${RELEASE_JSON}")
if [[ "${IS_PRE}" == "true" ]]; then
echo "Release ${TAG} is marked prerelease, skipping."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Collect commits between previous tag and current tag
id: commits
if: steps.gate.outputs.skip != 'true'
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
PREV_TAG=$(git tag --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -v "^${TAG}$" \
| head -n 1)
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}."
- name: Generate summary with AI
id: ai
if: steps.gate.outputs.skip != 'true'
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
with:
prompt-file: .github/prompts/telegram-release-summary.prompt.yml
input: |
version: ${{ steps.tag.outputs.tag }}
file_input: |
commits: ./commits.txt
max-tokens: 1024
- 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
# 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}"
VERSION_NUM="${TAG#v}"
RELEASE_URL="https://github.com/${REPO}/releases/tag/${VERSION}"
DL="https://github.com/${REPO}/releases/download/${VERSION}"
# Build the API payload in one jq pass — keeps every literal
# newline, every angle bracket, and every quote correctly escaped
# for both shell and JSON.
PAYLOAD=$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg version "$VERSION" \
--arg changes "$ESCAPED_CHANGES" \
--arg dl "$DL" \
--arg vnum "$VERSION_NUM" \
--arg release_url "$RELEASE_URL" \
'{
chat_id: $chat_id,
parse_mode: "HTML",
disable_web_page_preview: true,
text: (
"<b>Donut Browser " + $version + " released</b>\n\n" +
$changes + "\n" +
"<b>Download</b>\n" +
"<a href=\"" + $dl + "/Donut_" + $vnum + "_aarch64.dmg\">macOS (Apple Silicon)</a> · " +
"<a href=\"" + $dl + "/Donut_" + $vnum + "_x64.dmg\">macOS (Intel)</a>\n" +
"<a href=\"" + $dl + "/Donut_" + $vnum + "_x64-setup.exe\">Windows x64</a> · " +
"<a href=\"" + $dl + "/Donut_" + $vnum + "_amd64.AppImage\">Linux x64</a>\n\n" +
"<a href=\"" + $release_url + "\">Full release notes</a>"
)
}')
# Use --fail-with-body so we surface Telegram's error JSON on 4xx/5xx
# instead of just a curl exit code.
RESPONSE=$(curl -sSL --fail-with-body \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage") \
|| { echo "::error::Telegram API call failed"; echo "$RESPONSE"; exit 1; }
if [ "$(jq -r .ok <<< "$RESPONSE")" != "true" ]; then
echo "::error::Telegram API rejected the message:"
jq . <<< "$RESPONSE"
exit 1
fi
echo "Posted to Telegram (message_id $(jq -r .result.message_id <<< "$RESPONSE"))"
+8 -2
View File
@@ -23,6 +23,8 @@ on:
- "pnpm-lock.yaml"
- "src-tauri/Cargo.toml"
- "src-tauri/Cargo.lock"
- "nodecar/package.json"
- "nodecar/pnpm-lock.yaml"
- ".github/workflows/osv.yml"
merge_group:
branches: ["main"]
@@ -36,6 +38,8 @@ on:
- "pnpm-lock.yaml"
- "src-tauri/Cargo.toml"
- "src-tauri/Cargo.lock"
- "nodecar/package.json"
- "nodecar/pnpm-lock.yaml"
permissions:
security-events: write
@@ -46,23 +50,25 @@ jobs:
scan-scheduled:
name: Scheduled Security Scan
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
with:
scan-args: |-
-r
--skip-git
--lockfile=pnpm-lock.yaml
--lockfile=src-tauri/Cargo.lock
--lockfile=nodecar/pnpm-lock.yaml
./
scan-pr:
name: PR Security Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
with:
scan-args: |-
-r
--skip-git
--lockfile=pnpm-lock.yaml
--lockfile=src-tauri/Cargo.lock
--lockfile=nodecar/pnpm-lock.yaml
./
+3 -14
View File
@@ -29,26 +29,20 @@ jobs:
security-scan:
name: Security Vulnerability Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
with:
scan-args: |-
-r
--skip-git
--lockfile=pnpm-lock.yaml
--lockfile=nodecar/pnpm-lock.yaml
--lockfile=src-tauri/Cargo.lock
./
sync-e2e:
name: Sync E2E Tests
uses: ./.github/workflows/sync-e2e.yml
secrets: inherit
permissions:
contents: read
pr-status:
name: PR Status Check
runs-on: ubuntu-latest
needs: [lint-js, lint-rust, security-scan, sync-e2e]
needs: [lint-js, lint-rust, security-scan]
if: always()
steps:
- name: Check all jobs succeeded
@@ -57,9 +51,4 @@ jobs:
echo "One or more checks failed"
exit 1
fi
# sync-e2e is optional (only runs when sync-related files change)
if [[ "${{ needs.sync-e2e.result }}" == "failure" ]]; then
echo "Sync E2E tests failed"
exit 1
fi
echo "All checks passed!"
-74
View File
@@ -1,74 +0,0 @@
name: Publish Linux Repos
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g. v0.18.1). Leave empty for latest."
required: false
type: string
workflow_run:
workflows: ["Release"]
types:
- completed
permissions:
contents: read
jobs:
publish-repos:
if: >
github.repository == 'zhom/donutbrowser' &&
(github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Determine release tag
id: tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_TAG: ${{ inputs.tag }}
run: |
if [[ -n "${INPUT_TAG:-}" ]]; then
echo "tag=${INPUT_TAG}" >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then
# The Release workflow is triggered by a tag push (v*),
# so head_branch is the tag name
echo "tag=${{ github.event.workflow_run.head_branch }}" >> "$GITHUB_OUTPUT"
else
TAG=$(gh release view --repo "${{ github.repository }}" --json tagName -q .tagName)
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
fi
- name: Install tools (dpkg-dev, createrepo-c, aws-cli v1)
run: |
sudo apt-get update
sudo apt-get install -y dpkg-dev createrepo-c python3-pip
# GitHub runners ship aws-cli v2, which sends CRC64NVME integrity
# checksums that Cloudflare R2 rejects with `Unauthorized` on
# ListObjectsV2 (the call behind `aws s3 sync`). aws-cli v1 predates
# that behavior — the same reason scripts/publish-repo.sh works in
# Docker. Remove v2 and install v1 system-wide so it's the binary on
# PATH within this job, and fail fast if v2 somehow survives rather
# than letting the publish step die on an opaque Unauthorized later.
sudo rm -f /usr/local/bin/aws /usr/local/bin/aws_completer
sudo rm -rf /usr/local/aws-cli
sudo pip3 install --break-system-packages 'awscli<2'
hash -r
aws --version
case "$(aws --version 2>&1)" in
aws-cli/1.*) ;;
*) echo "::error::Expected aws-cli v1 but got: $(aws --version 2>&1)"; exit 1 ;;
esac
- name: Publish DEB & RPM repositories to R2
env:
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash scripts/publish-repo.sh "${{ steps.tag.outputs.tag }}"
+56 -56
View File
@@ -1,10 +1,8 @@
name: Generate Release Notes
on:
workflow_run:
workflows: ["Release"]
types:
- completed
release:
types: [published]
permissions:
contents: write
@@ -13,40 +11,19 @@ permissions:
jobs:
generate-release-notes:
runs-on: ubuntu-latest
if: github.repository == 'zhom/donutbrowser' && github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'v')
if: startsWith(github.ref, 'refs/tags/v') && !github.event.release.prerelease
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
with:
fetch-depth: 0
- name: Get release info
id: get-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.event.workflow_run.head_branch }}
run: |
echo "tag-name=$TAG_NAME" >> $GITHUB_OUTPUT
# Get release info by tag
RELEASE_INFO=$(gh api /repos/${{ github.repository }}/releases/tags/$TAG_NAME)
RELEASE_ID=$(echo "$RELEASE_INFO" | jq -r '.id')
IS_PRERELEASE=$(echo "$RELEASE_INFO" | jq -r '.prerelease')
echo "release-id=$RELEASE_ID" >> $GITHUB_OUTPUT
echo "is-prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT
if [ "$IS_PRERELEASE" = "true" ]; then
echo "Skipping release notes generation for prerelease"
fi
fetch-depth: 0 # Fetch full history to compare with previous release
- name: Get previous release tag
id: get-previous-tag
if: steps.get-release.outputs.is-prerelease == 'false'
env:
CURRENT_TAG: ${{ steps.get-release.outputs.tag-name }}
run: |
# Get the previous release tag (excluding the current one)
CURRENT_TAG="${{ github.ref_name }}"
PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | grep -v "$CURRENT_TAG" | head -n 1)
if [ -z "$PREVIOUS_TAG" ]; then
@@ -61,16 +38,16 @@ jobs:
- name: Get commit messages between releases
id: get-commits
if: steps.get-release.outputs.is-prerelease == 'false'
env:
PREVIOUS_TAG: ${{ steps.get-previous-tag.outputs.previous-tag }}
CURRENT_TAG: ${{ steps.get-previous-tag.outputs.current-tag }}
run: |
# Get commit messages between previous and current release
PREVIOUS_TAG="${{ steps.get-previous-tag.outputs.previous-tag }}"
CURRENT_TAG="${{ steps.get-previous-tag.outputs.current-tag }}"
# Get commit log with detailed format
COMMIT_LOG=$(git log --pretty=format:"- %s (%h by %an)" "$PREVIOUS_TAG".."$CURRENT_TAG" --no-merges)
COMMIT_LOG=$(git log --pretty=format:"- %s (%h by %an)" $PREVIOUS_TAG..$CURRENT_TAG --no-merges)
# Get changed files summary
CHANGED_FILES=$(git diff --name-status "$PREVIOUS_TAG".."$CURRENT_TAG" | head -20)
CHANGED_FILES=$(git diff --name-status $PREVIOUS_TAG..$CURRENT_TAG | head -20)
# Save to files for AI processing
echo "$COMMIT_LOG" > commits.txt
@@ -81,38 +58,61 @@ jobs:
- name: Generate release notes with AI
id: generate-notes
if: steps.get-release.outputs.is-prerelease == 'false'
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
uses: actions/ai-inference@d645f067d89ee1d5d736a5990e327e504d1c5a4a # v1.1.0
with:
prompt-file: .github/prompts/release-notes.prompt.yml
input: |
version: ${{ steps.get-previous-tag.outputs.current-tag }}
file_input: |
commits: ./commits.txt
max-tokens: 4096
prompt-file: commits.txt
system-prompt: |
You are an expert technical writer tasked with generating comprehensive release notes for Donut Browser, a powerful browser orchestrator.
Analyze the provided commit messages and generate well-structured release notes following this format:
## What's New in ${{ steps.get-previous-tag.outputs.current-tag }}
[Brief 1-2 sentence overview of the release]
### ✨ New Features
[List new features with brief descriptions]
### 🐛 Bug Fixes
[List bug fixes]
### 🔧 Improvements
[List improvements and enhancements]
### 📚 Documentation
[List documentation updates if any]
### 🔄 Dependencies
[List dependency updates if any]
### 🛠️ Developer Experience
[List development-related changes if any]
Guidelines:
- Use clear, user-friendly language
- Group related commits logically
- Omit minor commits like formatting, typos unless significant
- Focus on user-facing changes
- Use emojis sparingly and consistently
- Keep descriptions concise but informative
- If commits are unclear, infer the purpose from the context
The application is a desktop app built with Tauri + Next.js that helps users manage multiple browser profiles with proxy support.
model: gpt-4o
- name: Update release with generated notes
if: steps.get-release.outputs.is-prerelease == 'false'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RESPONSE_FILE: ${{ steps.generate-notes.outputs.response-file }}
RESPONSE_OUTPUT: ${{ steps.generate-notes.outputs.response }}
RELEASE_ID: ${{ steps.get-release.outputs.release-id }}
run: |
# Prefer reading from the response file to avoid output truncation
if [ -n "$RESPONSE_FILE" ] && [ -f "$RESPONSE_FILE" ]; then
RELEASE_NOTES=$(cat "$RESPONSE_FILE")
else
RELEASE_NOTES="$RESPONSE_OUTPUT"
fi
# Get the generated release notes
RELEASE_NOTES="${{ steps.generate-notes.outputs.response }}"
# Update the release with the generated notes
gh api --method PATCH /repos/${{ github.repository }}/releases/"$RELEASE_ID" \
gh api --method PATCH /repos/${{ github.repository }}/releases/${{ github.event.release.id }} \
--field body="$RELEASE_NOTES"
echo "✅ Release notes updated successfully!"
- name: Cleanup
if: always()
run: |
rm -f commits.txt changes.txt
+44 -493
View File
@@ -5,12 +5,6 @@ on:
tags:
- "v*"
permissions:
contents: write
security-events: write
packages: read
actions: read
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
@@ -18,15 +12,15 @@ env:
jobs:
security-scan:
if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
with:
scan-args: |-
-r
--skip-git
--lockfile=pnpm-lock.yaml
--lockfile=src-tauri/Cargo.lock
--lockfile=nodecar/pnpm-lock.yaml
./
permissions:
security-events: write
@@ -34,7 +28,6 @@ jobs:
actions: read
lint-js:
if: github.repository == 'zhom/donutbrowser'
name: Lint JavaScript/TypeScript
uses: ./.github/workflows/lint-js.yml
secrets: inherit
@@ -42,7 +35,6 @@ jobs:
contents: read
lint-rust:
if: github.repository == 'zhom/donutbrowser'
name: Lint Rust
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
@@ -50,7 +42,6 @@ jobs:
contents: read
codeql:
if: github.repository == 'zhom/donutbrowser'
name: CodeQL
uses: ./.github/workflows/codeql.yml
secrets: inherit
@@ -61,7 +52,6 @@ jobs:
actions: read
spellcheck:
if: github.repository == 'zhom/donutbrowser'
name: Spell Check
uses: ./.github/workflows/spellcheck.yml
secrets: inherit
@@ -69,7 +59,6 @@ jobs:
contents: read
release:
if: github.repository == 'zhom/donutbrowser'
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
permissions:
contents: write
@@ -78,48 +67,56 @@ jobs:
matrix:
include:
- platform: "macos-latest"
args: "--target aarch64-apple-darwin --verbose"
args: "--target aarch64-apple-darwin"
arch: "aarch64"
target: "aarch64-apple-darwin"
pkg_target: "latest-macos-arm64"
nodecar_script: "build:mac-aarch64"
- platform: "macos-latest"
args: "--target x86_64-apple-darwin --verbose"
args: "--target x86_64-apple-darwin"
arch: "x86_64"
target: "x86_64-apple-darwin"
pkg_target: "latest-macos-x64"
nodecar_script: "build:mac-x86_64"
- platform: "ubuntu-22.04"
args: "--target x86_64-unknown-linux-gnu --verbose"
args: "--target x86_64-unknown-linux-gnu"
arch: "x86_64"
target: "x86_64-unknown-linux-gnu"
pkg_target: "latest-linux-x64"
nodecar_script: "build:linux-x64"
- platform: "ubuntu-22.04-arm"
args: "--target aarch64-unknown-linux-gnu --verbose"
args: "--target aarch64-unknown-linux-gnu"
arch: "aarch64"
target: "aarch64-unknown-linux-gnu"
pkg_target: "latest-linux-arm64"
- platform: "windows-latest"
args: "--target x86_64-pc-windows-msvc --verbose"
arch: "x86_64"
target: "x86_64-pc-windows-msvc"
pkg_target: "latest-win-x64"
nodecar_script: "build:linux-arm64"
# - platform: "windows-latest"
# args: "--target x86_64-pc-windows-msvc"
# arch: "x86_64"
# target: "x86_64-pc-windows-msvc"
# pkg_target: "latest-win-x64"
# nodecar_script: "build:win-x64"
# - platform: "windows-11-arm"
# args: "--target aarch64-pc-windows-msvc"
# arch: "aarch64"
# target: "aarch64-pc-windows-msvc"
# pkg_target: "latest-win-arm64"
# nodecar_script: "build:win-arm64"
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Setup Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
with:
node-version-file: .node-version
cache: "pnpm"
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
- name: Setup Rust
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
with:
toolchain: stable
targets: ${{ matrix.target }}
@@ -128,102 +125,41 @@ jobs:
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config xdg-utils
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 #v2.8.0
with:
workdir: ./src-tauri
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Build frontend
# NEXT_PUBLIC_* vars are inlined at build time and must be forwarded
# from secrets explicitly — they are NOT inherited from the job env.
env:
NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
run: pnpm exec next build
- name: Verify frontend dist exists
- name: Build nodecar sidecar
shell: bash
working-directory: ./nodecar
run: |
if [ ! -d "dist" ]; then
echo "Error: dist directory not found after build"
ls -la
exit 1
fi
echo "Frontend dist directory verified at $(pwd)/dist"
echo "Checking from src-tauri perspective:"
ls -la src-tauri/../dist || echo "Warning: dist not accessible from src-tauri"
pnpm run ${{ matrix.nodecar_script }}
- name: Build sidecar binaries
shell: bash
working-directory: ./src-tauri
run: |
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
- name: Copy sidecar binaries to Tauri binaries
- name: Copy nodecar binary to Tauri binaries
shell: bash
run: |
mkdir -p src-tauri/binaries
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${{ matrix.target }}.exe
cp nodecar/dist/nodecar.exe src-tauri/binaries/nodecar-${{ matrix.target }}.exe
else
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy src-tauri/binaries/donut-proxy-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }}
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-${{ matrix.target }}
fi
- name: Import Apple certificate
if: matrix.platform == 'macos-latest'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_KEY: ${{ secrets.APPLE_CERTIFICATE_KEY }}
run: |
CERT_PATH=$RUNNER_TEMP/cert.cer
KEY_PATH=$RUNNER_TEMP/cert.key
PEM_PATH=$RUNNER_TEMP/cert.pem
P12_PATH=$RUNNER_TEMP/build_certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
P12_PASSWORD=$(openssl rand -base64 32)
echo "$APPLE_CERTIFICATE" | base64 --decode > $CERT_PATH
echo "$APPLE_CERTIFICATE_KEY" | base64 --decode > $KEY_PATH
openssl x509 -inform DER -in $CERT_PATH -out $PEM_PATH
openssl pkcs12 -export -out $P12_PATH -inkey $KEY_PATH -in $PEM_PATH -passout pass:$P12_PASSWORD
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import $P12_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH login.keychain-db
echo "Available signing identities:"
security find-identity -v -p codesigning $KEYCHAIN_PATH
rm -f $CERT_PATH $KEY_PATH $PEM_PATH $P12_PATH
- name: Build frontend
run: pnpm build
- name: Build Tauri app
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 #v0.6.2
uses: tauri-apps/tauri-action@564aea5a8075c7a54c167bb0cf5b3255314a7f9d #v0.5.22
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REF_NAME: ${{ github.ref_name }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# tauri-action invokes `pnpm tauri build`, which runs
# `beforeBuildCommand` from tauri.conf.json. That rebuilds the
# frontend in its own subprocess, so the env var MUST be forwarded
# here or the inner `next build` inlines an empty string and
# overwrites the dist the explicit "Build frontend" step produced.
NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
with:
projectPath: ./src-tauri
tagName: ${{ github.ref_name }}
releaseName: "Donut Browser ${{ github.ref_name }}"
releaseBody: "See the assets to download this version and install."
@@ -231,394 +167,9 @@ jobs:
prerelease: false
args: ${{ matrix.args }}
- name: Create portable Windows ZIP
if: matrix.platform == 'windows-latest'
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
VERSION="${TAG#v}"
PORTABLE_DIR="Donut-Portable"
mkdir -p "$PORTABLE_DIR"
# Copy main executable
cp "src-tauri/target/${{ matrix.target }}/release/donutbrowser.exe" "$PORTABLE_DIR/Donut.exe"
# Copy sidecar binaries
cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/"
# The daemon is currently disabled (no Cargo bin target), so it isn't
# built. Copy it only if a build produced it, so the absent binary
# doesn't fail the job.
if [ -f "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" ]; then
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
fi
# Copy WebView2Loader if present
if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
cp "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" "$PORTABLE_DIR/"
fi
# Create .portable marker
touch "$PORTABLE_DIR/.portable"
# Create ZIP
7z a "Donut_${VERSION}_x64-portable.zip" "$PORTABLE_DIR"
- name: Upload portable ZIP to release
if: matrix.platform == 'windows-latest'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
VERSION="${TAG#v}"
gh release upload "$TAG" "Donut_${VERSION}_x64-portable.zip" --clobber
- name: Clean up Apple certificate
if: matrix.platform == 'macos-latest' && always()
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
rm -f $RUNNER_TEMP/build_certificate.p12 || true
changelog:
if: github.repository == 'zhom/donutbrowser'
needs: [release]
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Commit CHANGELOG.md
uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0 #v6.0.1
with:
ref: main
fetch-depth: 0
- name: Generate changelog
env:
TAG: ${{ github.ref_name }}
run: |
PREV_TAG=$(git tag --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -v "^${TAG}$" \
| head -n 1)
if [ -z "$PREV_TAG" ]; then
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
fi
echo "Generating changelog: ${PREV_TAG}..${TAG}"
features=""
fixes=""
refactors=""
perf=""
docs=""
maintenance=""
other=""
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
while IFS= read -r msg; do
[ -z "$msg" ] && continue
case "$msg" in
feat\(*\):*|feat:*)
features="${features}- $(strip_prefix "$msg")"$'\n' ;;
fix\(*\):*|fix:*)
fixes="${fixes}- $(strip_prefix "$msg")"$'\n' ;;
refactor\(*\):*|refactor:*)
refactors="${refactors}- $(strip_prefix "$msg")"$'\n' ;;
perf\(*\):*|perf:*)
perf="${perf}- $(strip_prefix "$msg")"$'\n' ;;
docs\(*\):*|docs:*)
docs="${docs}- $(strip_prefix "$msg")"$'\n' ;;
build*|ci*|chore*|test*)
maintenance="${maintenance}- ${msg}"$'\n' ;;
*)
other="${other}- ${msg}"$'\n' ;;
esac
done < <(git log --pretty=format:"%s" "${PREV_TAG}..${TAG}" --no-merges)
{
echo "## ${TAG} ($(date -u +%Y-%m-%d))"
echo ""
[ -n "$features" ] && printf "### Features\n\n%s\n" "$features"
[ -n "$fixes" ] && printf "### Bug Fixes\n\n%s\n" "$fixes"
[ -n "$refactors" ] && printf "### Refactoring\n\n%s\n" "$refactors"
[ -n "$perf" ] && printf "### Performance\n\n%s\n" "$perf"
[ -n "$docs" ] && printf "### Documentation\n\n%s\n" "$docs"
[ -n "$maintenance" ] && printf "### Maintenance\n\n%s\n" "$maintenance"
[ -n "$other" ] && printf "### Other\n\n%s\n" "$other"
} > /tmp/release-changelog.md
echo "Generated changelog:"
cat /tmp/release-changelog.md
- name: Update CHANGELOG.md
run: |
if [ -f CHANGELOG.md ]; then
# Insert new entry after the "# Changelog" header (first 2 lines)
{
head -n 2 CHANGELOG.md
echo ""
cat /tmp/release-changelog.md
tail -n +3 CHANGELOG.md
} > CHANGELOG.tmp
mv CHANGELOG.tmp CHANGELOG.md
else
{
echo "# Changelog"
echo ""
cat /tmp/release-changelog.md
} > CHANGELOG.md
fi
- name: Update README download links
env:
TAG: ${{ github.ref_name }}
run: |
VERSION="${TAG#v}"
BASE="https://github.com/zhom/donutbrowser/releases/download/${TAG}"
# Generate the new install section between markers
cat > /tmp/install-links.md << LINKS
### macOS
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](${BASE}/Donut_${VERSION}_aarch64.dmg) | [Download](${BASE}/Donut_${VERSION}_x64.dmg) |
Or install via Homebrew:
\`\`\`bash
brew install --cask donut
\`\`\`
### Windows
[Download Windows Installer (x64)](${BASE}/Donut_${VERSION}_x64-setup.exe) · [Portable (x64)](${BASE}/Donut_${VERSION}_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](${BASE}/Donut_${VERSION}_amd64.deb) | [Download](${BASE}/Donut_${VERSION}_arm64.deb) |
| **rpm** | [Download](${BASE}/Donut-${VERSION}-1.x86_64.rpm) | [Download](${BASE}/Donut-${VERSION}-1.aarch64.rpm) |
| **AppImage** | [Download](${BASE}/Donut_${VERSION}_amd64.AppImage) | [Download](${BASE}/Donut_${VERSION}_aarch64.AppImage) |
LINKS
# Strip leading whitespace from heredoc
sed -i 's/^ //' /tmp/install-links.md
# Replace content between markers in README
sed -i '/<!-- install-links-start -->/,/<!-- install-links-end -->/{
/<!-- install-links-start -->/{
p
r /tmp/install-links.md
}
/<!-- install-links-end -->/!d
}' README.md
- name: Create release docs PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
VERSION="${TAG#v}"
BRANCH="docs/release-${VERSION}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add CHANGELOG.md README.md
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "docs: update CHANGELOG.md and README.md for ${TAG} [skip ci]"
git push origin "$BRANCH"
gh pr create \
--title "docs: release notes for ${TAG}" \
--body "Automated update of CHANGELOG.md and README.md download links for ${TAG}." \
--base main \
--head "$BRANCH"
gh pr merge "$BRANCH" --squash --admin
fi
- name: Update release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
gh release edit "$TAG" --notes-file /tmp/release-changelog.md
notify-discord:
if: github.repository == 'zhom/donutbrowser'
needs: [release, changelog]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
ref: main
fetch-depth: 0
- name: Generate changelog summary
env:
TAG: ${{ github.ref_name }}
run: |
PREV_TAG=$(git tag --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -v "^${TAG}$" \
| head -n 1)
if [ -z "$PREV_TAG" ]; then
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
fi
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
CHANGES=""
while IFS= read -r msg; do
[ -z "$msg" ] && continue
case "$msg" in
feat\(*\):*|feat:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
fix\(*\):*|fix:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
refactor\(*\):*|refactor:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
perf\(*\):*|perf:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
esac
done < <(git log --pretty=format:"%s" "${PREV_TAG}..${TAG}" --no-merges)
# Truncate to fit Discord embed (max 4096 chars)
if [ ${#CHANGES} -gt 3900 ]; then
CHANGES="${CHANGES:0:3900}\n..."
fi
if [ -z "$CHANGES" ]; then
CHANGES="See the full changelog on GitHub."
fi
printf '%b' "$CHANGES" > /tmp/discord-changes.txt
- name: Send Discord notification
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_STABLE_WEBHOOK_URL }}
TAG: ${{ github.ref_name }}
run: |
VERSION="${TAG}"
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${VERSION}"
CHANGES=$(cat /tmp/discord-changes.txt)
# Build JSON with jq to handle escaping
PAYLOAD=$(jq -n \
--arg title "Donut Browser ${VERSION} Released" \
--arg url "$RELEASE_URL" \
--arg changes "$CHANGES" \
--arg dl_mac_arm "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_aarch64.dmg" \
--arg dl_mac_intel "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_x64.dmg" \
--arg dl_win "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_x64-setup.exe" \
--arg dl_linux "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_amd64.AppImage" \
'{
embeds: [{
title: $title,
url: $url,
description: $changes,
color: 5814783,
fields: [
{ name: "Download", value: ("[macOS (Apple Silicon)](" + $dl_mac_arm + ") · [macOS (Intel)](" + $dl_mac_intel + ")\n[Windows x64](" + $dl_win + ") · [Linux x64](" + $dl_linux + ")"), inline: false }
],
footer: { text: "donutbrowser.com" }
}]
}')
curl -fsSL -H "Content-Type: application/json" -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL"
deploy-website:
if: github.repository == 'zhom/donutbrowser'
needs: [release]
runs-on: ubuntu-latest
steps:
- name: Trigger Cloudflare Pages deployment
run: curl -fsSL -X POST "${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}"
docker:
if: github.repository == 'zhom/donutbrowser'
needs: [release]
uses: ./.github/workflows/docker-sync.yml
with:
tag: ${{ github.ref_name }}
secrets: inherit
update-flake:
if: github.repository == 'zhom/donutbrowser'
needs: [release, changelog]
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
with:
ref: main
- name: Compute AppImage hashes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
VERSION="${TAG#v}"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
AMD64_URL="https://github.com/zhom/donutbrowser/releases/download/${TAG}/Donut_${VERSION}_amd64.AppImage"
AARCH64_URL="https://github.com/zhom/donutbrowser/releases/download/${TAG}/Donut_${VERSION}_aarch64.AppImage"
echo "Downloading x86_64 AppImage..."
curl -fsSL -o /tmp/amd64.AppImage "$AMD64_URL" || { echo "x86_64 AppImage not found"; exit 1; }
echo "Downloading aarch64 AppImage..."
curl -fsSL -o /tmp/aarch64.AppImage "$AARCH64_URL" || { echo "aarch64 AppImage not found"; exit 1; }
# Compute SRI hashes (sha256-<base64>)
AMD64_HASH="sha256-$(sha256sum /tmp/amd64.AppImage | awk '{print $1}' | xxd -r -p | base64 | tr -d '\n')"
AARCH64_HASH="sha256-$(sha256sum /tmp/aarch64.AppImage | awk '{print $1}' | xxd -r -p | base64 | tr -d '\n')"
echo "AMD64_HASH=${AMD64_HASH}" >> "$GITHUB_ENV"
echo "AARCH64_HASH=${AARCH64_HASH}" >> "$GITHUB_ENV"
echo "AMD64_URL=${AMD64_URL}" >> "$GITHUB_ENV"
echo "AARCH64_URL=${AARCH64_URL}" >> "$GITHUB_ENV"
echo "x86_64 hash: ${AMD64_HASH}"
echo "aarch64 hash: ${AARCH64_HASH}"
- name: Update flake.nix
run: |
# Update releaseVersion
sed -i "s/releaseVersion = \"[^\"]*\"/releaseVersion = \"${VERSION}\"/" flake.nix
# Update x86_64 URL and hash
sed -i "s|url = \"https://github.com/zhom/donutbrowser/releases/download/v[^\"]*_amd64.AppImage\"|url = \"${AMD64_URL}\"|" flake.nix
sed -i "/amd64.AppImage/{ n; s|hash = \"[^\"]*\"|hash = \"${AMD64_HASH}\"|; }" flake.nix
# Update aarch64 URL and hash
sed -i "s|url = \"https://github.com/zhom/donutbrowser/releases/download/v[^\"]*_aarch64.AppImage\"|url = \"${AARCH64_URL}\"|" flake.nix
sed -i "/aarch64.AppImage/{ n; s|hash = \"[^\"]*\"|hash = \"${AARCH64_HASH}\"|; }" flake.nix
echo "Updated flake.nix:"
grep -n "releaseVersion\|AppImage\|hash = " flake.nix
- name: Create pull request
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BRANCH="chore/update-flake-${VERSION}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add flake.nix
if git diff --cached --quiet; then
echo "No flake changes needed"
exit 0
fi
git commit -m "chore: update flake.nix for v${VERSION} [skip ci]"
git push origin "$BRANCH"
gh pr create \
--title "chore: update flake.nix for v${VERSION}" \
--body "Automated update of flake.nix with new AppImage hashes for v${VERSION}." \
--base main \
--head "$BRANCH"
gh pr merge "$BRANCH" --squash --admin
branch: main
commit_message: "docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]"
file_pattern: CHANGELOG.md
+35 -280
View File
@@ -5,27 +5,21 @@ on:
branches:
- main
permissions:
contents: write
security-events: write
packages: read
actions: read
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
jobs:
security-scan:
if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
with:
scan-args: |-
-r
--skip-git
--lockfile=pnpm-lock.yaml
--lockfile=src-tauri/Cargo.lock
--lockfile=nodecar/pnpm-lock.yaml
./
permissions:
security-events: write
@@ -33,7 +27,6 @@ jobs:
actions: read
lint-js:
if: github.repository == 'zhom/donutbrowser'
name: Lint JavaScript/TypeScript
uses: ./.github/workflows/lint-js.yml
secrets: inherit
@@ -41,7 +34,6 @@ jobs:
contents: read
lint-rust:
if: github.repository == 'zhom/donutbrowser'
name: Lint Rust
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
@@ -49,7 +41,6 @@ jobs:
contents: read
codeql:
if: github.repository == 'zhom/donutbrowser'
name: CodeQL
uses: ./.github/workflows/codeql.yml
secrets: inherit
@@ -60,7 +51,6 @@ jobs:
actions: read
spellcheck:
if: github.repository == 'zhom/donutbrowser'
name: Spell Check
uses: ./.github/workflows/spellcheck.yml
secrets: inherit
@@ -68,7 +58,6 @@ jobs:
contents: read
rolling-release:
if: github.repository == 'zhom/donutbrowser'
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
permissions:
contents: write
@@ -77,48 +66,56 @@ jobs:
matrix:
include:
- platform: "macos-latest"
args: "--target aarch64-apple-darwin --verbose"
args: "--target aarch64-apple-darwin"
arch: "aarch64"
target: "aarch64-apple-darwin"
pkg_target: "latest-macos-arm64"
nodecar_script: "build:mac-aarch64"
- platform: "macos-latest"
args: "--target x86_64-apple-darwin --verbose"
args: "--target x86_64-apple-darwin"
arch: "x86_64"
target: "x86_64-apple-darwin"
pkg_target: "latest-macos-x64"
nodecar_script: "build:mac-x86_64"
- platform: "ubuntu-22.04"
args: "--target x86_64-unknown-linux-gnu --verbose"
args: "--target x86_64-unknown-linux-gnu"
arch: "x86_64"
target: "x86_64-unknown-linux-gnu"
pkg_target: "latest-linux-x64"
nodecar_script: "build:linux-x64"
- platform: "ubuntu-22.04-arm"
args: "--target aarch64-unknown-linux-gnu --verbose"
args: "--target aarch64-unknown-linux-gnu"
arch: "aarch64"
target: "aarch64-unknown-linux-gnu"
pkg_target: "latest-linux-arm64"
nodecar_script: "build:linux-arm64"
- platform: "windows-latest"
args: "--target x86_64-pc-windows-msvc --verbose"
args: "--target x86_64-pc-windows-msvc"
arch: "x86_64"
target: "x86_64-pc-windows-msvc"
pkg_target: "latest-win-x64"
nodecar_script: "build:win-x64"
- platform: "windows-11-arm"
args: "--target aarch64-pc-windows-msvc"
arch: "aarch64"
target: "aarch64-pc-windows-msvc"
pkg_target: "latest-win-arm64"
nodecar_script: "build:win-arm64"
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Setup Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
with:
node-version-file: .node-version
cache: "pnpm"
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
- name: Setup Rust
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
with:
toolchain: stable
targets: ${{ matrix.target }}
@@ -127,84 +124,34 @@ jobs:
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config xdg-utils
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 #v2.8.0
with:
workdir: ./src-tauri
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Build frontend
# NEXT_PUBLIC_* vars are inlined at build time and must be forwarded
# from secrets explicitly — they are NOT inherited from the job env.
env:
NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
run: pnpm exec next build
- name: Verify frontend dist exists
- name: Build nodecar sidecar
shell: bash
working-directory: ./nodecar
run: |
if [ ! -d "dist" ]; then
echo "Error: dist directory not found after build"
ls -la
exit 1
fi
echo "Frontend dist directory verified at $(pwd)/dist"
echo "Checking from src-tauri perspective:"
ls -la src-tauri/../dist || echo "Warning: dist not accessible from src-tauri"
pnpm run ${{ matrix.nodecar_script }}
- name: Build sidecar binaries
shell: bash
working-directory: ./src-tauri
run: |
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
- name: Copy sidecar binaries to Tauri binaries
- name: Copy nodecar binary to Tauri binaries
shell: bash
run: |
mkdir -p src-tauri/binaries
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${{ matrix.target }}.exe
cp nodecar/dist/nodecar.exe src-tauri/binaries/nodecar-${{ matrix.target }}.exe
else
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy src-tauri/binaries/donut-proxy-${{ matrix.target }}
chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }}
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-${{ matrix.target }}
fi
- name: Import Apple certificate
if: matrix.platform == 'macos-latest'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_KEY: ${{ secrets.APPLE_CERTIFICATE_KEY }}
run: |
CERT_PATH=$RUNNER_TEMP/cert.cer
KEY_PATH=$RUNNER_TEMP/cert.key
PEM_PATH=$RUNNER_TEMP/cert.pem
P12_PATH=$RUNNER_TEMP/build_certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
P12_PASSWORD=$(openssl rand -base64 32)
echo "$APPLE_CERTIFICATE" | base64 --decode > $CERT_PATH
echo "$APPLE_CERTIFICATE_KEY" | base64 --decode > $KEY_PATH
openssl x509 -inform DER -in $CERT_PATH -out $PEM_PATH
openssl pkcs12 -export -out $P12_PATH -inkey $KEY_PATH -in $PEM_PATH -passout pass:$P12_PASSWORD
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import $P12_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH login.keychain-db
echo "Available signing identities:"
security find-identity -v -p codesigning $KEYCHAIN_PATH
rm -f $CERT_PATH $KEY_PATH $PEM_PATH $P12_PATH
- name: Build frontend
run: pnpm build
- name: Generate nightly timestamp
id: timestamp
@@ -216,208 +163,16 @@ jobs:
echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"
- name: Build Tauri app
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 #v0.6.2
uses: tauri-apps/tauri-action@564aea5a8075c7a54c167bb0cf5b3255314a7f9d #v0.5.22
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
GITHUB_REF_NAME: "nightly-${{ steps.timestamp.outputs.timestamp }}"
GITHUB_SHA: ${{ github.sha }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# tauri-action's inner `pnpm tauri build` re-runs beforeBuildCommand
# which rebuilds dist/ in a subprocess. The env var must be here too.
NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
with:
projectPath: ./src-tauri
tagName: "nightly-${{ steps.timestamp.outputs.timestamp }}"
releaseName: "Donut Browser Nightly (Build ${{ steps.timestamp.outputs.timestamp }})"
releaseBody: "⚠️ **Nightly Release** - This is an automatically generated pre-release build from the latest main branch. Use with caution.\n\nCommit: ${{ github.sha }}\nBuild: ${{ steps.timestamp.outputs.timestamp }}"
releaseDraft: false
prerelease: true
args: ${{ matrix.args }}
- name: Create portable Windows ZIP
if: matrix.platform == 'windows-latest'
shell: bash
run: |
PORTABLE_DIR="Donut-Portable"
mkdir -p "$PORTABLE_DIR"
cp "src-tauri/target/${{ matrix.target }}/release/donutbrowser.exe" "$PORTABLE_DIR/Donut.exe"
cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/"
# The daemon is currently disabled (no Cargo bin target), so it isn't
# built. Copy it only if a build produced it, so the absent binary
# doesn't fail the job.
if [ -f "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" ]; then
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
fi
if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
cp "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" "$PORTABLE_DIR/"
fi
touch "$PORTABLE_DIR/.portable"
7z a "Donut_x64-portable.zip" "$PORTABLE_DIR"
- name: Upload portable ZIP to release
if: matrix.platform == 'windows-latest'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NIGHTLY_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
run: |
gh release upload "$NIGHTLY_TAG" "Donut_x64-portable.zip" --clobber
- name: Clean up Apple certificate
if: matrix.platform == 'macos-latest' && always()
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
rm -f $RUNNER_TEMP/build_certificate.p12 || true
update-nightly-release:
if: github.repository == 'zhom/donutbrowser'
needs: [rolling-release]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- name: Generate nightly tag
id: tag
run: |
TIMESTAMP=$(date -u +"%Y-%m-%d")
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
echo "nightly_tag=nightly-${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
- name: Generate nightly changelog
id: nightly-changelog
run: |
LAST_STABLE=$(git tag --sort=-version:refname \
| grep -E "^v[0-9]+\.[0-9]+\.[0-9]+\$" \
| head -n 1)
if [ -z "$LAST_STABLE" ]; then
LAST_STABLE=$(git rev-list --max-parents=0 HEAD)
fi
COMMIT_SHORT=$(echo "${GITHUB_SHA}" | cut -c1-7)
{
echo "**Nightly build from main branch**"
echo ""
echo "Commit: ${GITHUB_SHA}"
echo "Changes since ${LAST_STABLE}:"
echo ""
} > /tmp/nightly-notes.md
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
features=""
fixes=""
refactors=""
other=""
while IFS= read -r msg; do
[ -z "$msg" ] && continue
case "$msg" in
feat\(*\):*|feat:*)
features="${features}- $(strip_prefix "$msg")"$'\n' ;;
fix\(*\):*|fix:*)
fixes="${fixes}- $(strip_prefix "$msg")"$'\n' ;;
refactor\(*\):*|refactor:*)
refactors="${refactors}- $(strip_prefix "$msg")"$'\n' ;;
build*|ci*|chore*|test*|docs*|perf*)
;; # skip maintenance commits from nightly notes
*)
other="${other}- ${msg}"$'\n' ;;
esac
done < <(git log --pretty=format:"%s" "${LAST_STABLE}..HEAD" --no-merges)
{
[ -n "$features" ] && printf "### Features\n\n%s\n" "$features"
[ -n "$fixes" ] && printf "### Bug Fixes\n\n%s\n" "$fixes"
[ -n "$refactors" ] && printf "### Refactoring\n\n%s\n" "$refactors"
[ -n "$other" ] && printf "### Other\n\n%s\n" "$other"
true
} >> /tmp/nightly-notes.md
- name: Update rolling nightly release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
NIGHTLY_TAG="${{ steps.tag.outputs.nightly_tag }}"
ASSETS_DIR="/tmp/nightly-assets"
# Download all assets from the per-commit nightly release
mkdir -p "$ASSETS_DIR"
gh release download "$NIGHTLY_TAG" --dir "$ASSETS_DIR" --clobber
# Rename versioned filenames to stable nightly names
cd "$ASSETS_DIR"
for f in Donut_*_aarch64.dmg; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.dmg; done
for f in Donut_*_x64.dmg; do [ -f "$f" ] && mv "$f" Donut_nightly_x64.dmg; done
for f in Donut_*_x64-setup.exe; do [ -f "$f" ] && mv "$f" Donut_nightly_x64-setup.exe; done
for f in Donut_*_aarch64.AppImage; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.AppImage; done
for f in Donut_*_amd64.AppImage; do [ -f "$f" ] && mv "$f" Donut_nightly_amd64.AppImage; done
for f in Donut_*_amd64.deb; do [ -f "$f" ] && mv "$f" Donut_nightly_amd64.deb; done
for f in Donut_*_arm64.deb; do [ -f "$f" ] && mv "$f" Donut_nightly_arm64.deb; done
for f in Donut-*.x86_64.rpm; do [ -f "$f" ] && mv "$f" Donut_nightly_x86_64.rpm; done
for f in Donut-*.aarch64.rpm; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.rpm; done
cd "$GITHUB_WORKSPACE"
# Delete existing rolling nightly release and tag
gh release delete nightly --yes 2>/dev/null || true
git push --delete origin nightly 2>/dev/null || true
# Create new rolling nightly release with all assets
gh release create nightly \
"$ASSETS_DIR"/Donut_nightly_* \
"$ASSETS_DIR"/Donut_aarch64.app.tar.gz \
"$ASSETS_DIR"/Donut_x64.app.tar.gz \
--title "Donut Browser Nightly" \
--notes-file /tmp/nightly-notes.md \
--prerelease
deploy-website:
if: github.repository == 'zhom/donutbrowser'
needs: [update-nightly-release]
runs-on: ubuntu-latest
steps:
- name: Trigger Cloudflare Pages deployment
run: curl -fsSL -X POST "${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}"
notify-discord:
if: github.repository == 'zhom/donutbrowser'
needs: [update-nightly-release]
runs-on: ubuntu-latest
steps:
- name: Send Discord notification
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_NIGHTLY_WEBHOOK_URL }}
run: |
COMMIT_SHORT=$(echo "${GITHUB_SHA}" | cut -c1-7)
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/nightly"
COMMIT_URL="https://github.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}"
PAYLOAD=$(jq -n \
--arg title "Donut Browser Nightly (${COMMIT_SHORT})" \
--arg url "$RELEASE_URL" \
--arg commit_url "$COMMIT_URL" \
--arg commit_short "$COMMIT_SHORT" \
'{
embeds: [{
title: $title,
url: $url,
color: 16752128,
fields: [
{ name: "Commit", value: ("[" + $commit_short + "](" + $commit_url + ")"), inline: true },
{ name: "Download", value: ("[Nightly Release](" + $url + ")"), inline: true }
],
footer: { text: "donutbrowser.com" }
}]
}')
curl -fsSL -H "Content-Type: application/json" -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL"
+2 -2
View File
@@ -21,6 +21,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Actions Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
- name: Spell Check Repo
uses: crate-ci/typos@f8a58b6b53f2279f71eb605f03a4ae4d10608f45 #v1.47.0
uses: crate-ci/typos@392b78fe18a52790c53f42456e46124f77346842 #v1.34.0
+3 -6
View File
@@ -6,19 +6,16 @@ on:
jobs:
stale:
if: github.repository == 'zhom/donutbrowser'
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open."
stale-pr-message: "This pull request has been inactive for 30 days. Please respond to keep it open."
stale-issue-message: "This issue has been inactive for 60 days. Please respond to keep it open."
stale-pr-message: "This pull request has been inactive for 60 days. Please respond to keep it open."
stale-issue-label: "stale"
stale-pr-label: "stale"
days-before-stale: 30
days-before-close: 7
-119
View File
@@ -1,119 +0,0 @@
name: Sync E2E Tests
on:
pull_request:
branches: ["main"]
paths:
- "donut-sync/**"
- "src-tauri/src/sync/**"
- "scripts/sync-test-harness.mjs"
- ".github/workflows/sync-e2e.yml"
push:
branches: ["main"]
paths:
- "donut-sync/**"
- "src-tauri/src/sync/**"
- "scripts/sync-test-harness.mjs"
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
rust-sync-e2e:
name: Rust Sync E2E Tests
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-22.04]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
- name: Install pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
cache: "pnpm"
- name: Install Rust
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
with:
toolchain: stable
- name: Cache Rust dependencies
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
with:
workspaces: "src-tauri"
- name: Install Tauri dependencies (Ubuntu only)
if: matrix.os == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libxdo-dev
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run Rust sync e2e tests with harness
run: node scripts/sync-test-harness.mjs
donut-sync-e2e:
name: donut-sync Node.js E2E Tests
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
- name: Start MinIO
run: |
docker run -d --name minio \
-p 8987:9000 \
-e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=minioadmin \
minio/minio:latest server /data
# Wait for MinIO to be ready
for i in {1..30}; do
if curl -sf http://127.0.0.1:8987/minio/health/live; then
echo "MinIO is ready"
break
fi
echo "Waiting for MinIO... ($i/30)"
sleep 2
done
- name: Install pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run donut-sync Node.js e2e tests
working-directory: donut-sync
env:
SYNC_TOKEN: test-sync-token
S3_ENDPOINT: http://127.0.0.1:8987
S3_ACCESS_KEY_ID: minioadmin
S3_SECRET_ACCESS_KEY: minioadmin
S3_BUCKET: donut-sync-test
S3_FORCE_PATH_STYLE: "true"
run: pnpm test:e2e
+1 -16
View File
@@ -46,19 +46,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
!**/.gitkeep
# nodecar
nodecar/nodecar-bin
# sync test harness cache
.cache/
# env
.env
# next
**/next-env.d.ts
# claude
.claude/
!**/.gitkeep
-10
View File
@@ -1,10 +0,0 @@
# Prevent pushing the 'nightly' tag — it is managed by CI
if git rev-parse nightly >/dev/null 2>&1; then
LOCAL_NIGHTLY=$(git rev-parse nightly)
REMOTE_NIGHTLY=$(git ls-remote --tags origin refs/tags/nightly 2>/dev/null | awk '{print $1}')
if [ -n "$REMOTE_NIGHTLY" ] && [ "$LOCAL_NIGHTLY" != "$REMOTE_NIGHTLY" ]; then
echo "⚠ Skipping push of 'nightly' tag (managed by CI)"
# Delete the local nightly tag so --tags won't try to push it
git tag -d nightly >/dev/null 2>&1 || true
fi
fi
+1 -1
View File
@@ -1,2 +1,2 @@
23
24
+1 -1
View File
@@ -1 +1 @@
23
24
+9 -196
View File
@@ -1,269 +1,100 @@
{
"cSpell.words": [
"ABORTIFHUNG",
"aboutwelcome",
"adwaita",
"ahooks",
"akhilmhdh",
"anomalyco",
"appimage",
"appindicator",
"applescript",
"asyncio",
"autocheckpoint",
"autoconfig",
"autologin",
"bintools",
"biomejs",
"boringtun",
"breezedark",
"browserforge",
"Buildx",
"busctl",
"CAMOU",
"camoufox",
"catppuccin",
"cdylib",
"certifi",
"CFURL",
"checkin",
"chrono",
"ciphertext",
"cksum",
"CLICOLOR",
"clippy",
"cmdk",
"codegen",
"codesign",
"codesigning",
"commitish",
"coreutils",
"Crashpad",
"CTYPE",
"daijro",
"dataclasses",
"datareporting",
"datas",
"DBAPI",
"dbus",
"dconf",
"debuginfo",
"desynced",
"devedition",
"direnv",
"diskutil",
"distro",
"dists",
"DMABUF",
"DOCKERHUB",
"doctest",
"doesn",
"domcontentloaded",
"dont",
"donutbrowser",
"doorhanger",
"dpkg",
"dtolnay",
"dyld",
"elif",
"erasevolume",
"errorlevel",
"esac",
"esbuild",
"etree",
"fetchurl",
"findutils",
"firstrun",
"flate",
"fontconfig",
"freetype",
"fribidi",
"frontmost",
"fsprogs",
"geoip",
"getcwd",
"gettimezone",
"gifs",
"globset",
"gnugrep",
"gnumake",
"gnused",
"GOPATH",
"gsettings",
"harfbuzz",
"healthreport",
"hiddenimports",
"hkcu",
"hooksconfig",
"hookspath",
"hostable",
"Hoverable",
"icns",
"idlelib",
"idletime",
"idna",
"imdisk",
"infobars",
"inkey",
"Inno",
"isps",
"kdeglobals",
"keras",
"KHTML",
"killall",
"Kolkata",
"kreadconfig",
"langpack",
"launchservices",
"letterboxing",
"leveldb",
"libappindicator",
"libatk",
"libayatana",
"libc",
"libcairo",
"libdrm",
"libfuse",
"libgbm",
"libgdk",
"libglib",
"libglvnd",
"libgpg",
"libpango",
"librsvg",
"libsoup",
"libwebkit",
"libx",
"libxcb",
"libxcomposite",
"libxcursor",
"libxdamage",
"libxdo",
"libxext",
"libxfixes",
"libxi",
"libxinerama",
"libxkbcommon",
"libxrandr",
"libxrender",
"libxscrnsaver",
"libxshmfence",
"libxtst",
"localipv",
"localtime",
"lpdw",
"lxml",
"lzma",
"macchiato",
"Matchalk",
"maxminddb",
"minidumps",
"minioadmin",
"memorysaver",
"mmdb",
"mountpoint",
"msiexec",
"mstone",
"msvc",
"msys",
"muda",
"mypy",
"nixos",
"nixpkgs",
"noarchive",
"nobrowse",
"noconfirm",
"Mullvad",
"mullvadbrowser",
"nodecar",
"NODELAY",
"nodemon",
"nomount",
"norestart",
"NSIS",
"nspr",
"ntfs",
"ntlm",
"numpy",
"numtide",
"objc",
"oneshot",
"opencode",
"OPENROUTER",
"orhun",
"orjson",
"osascript",
"oscpu",
"outpath",
"pango",
"passout",
"patchelf",
"pathex",
"pathlib",
"peerconnection",
"PHANDLER",
"pids",
"pipefail",
"pixbuf",
"pkexec",
"pkgs",
"pkill",
"pkgman",
"plasmohq",
"platformdirs",
"pname",
"postject",
"prefs",
"presign",
"PRIO",
"propertylist",
"psutil",
"pycache",
"pydantic",
"pyee",
"pyinstaller",
"pyoxidizer",
"pytest",
"pyyaml",
"quic",
"ralt",
"ramdisk",
"rawfile",
"repodata",
"repogen",
"reportingpolicy",
"reqwest",
"resvg",
"ridedott",
"rlib",
"rsplit",
"rusqlite",
"rustc",
"rwxr",
"safebrowsing",
"SARIF",
"sarifv",
"scipy",
"screeninfo",
"selectables",
"serde",
"sessionstore",
"setpriority",
"setsid",
"SETTINGCHANGE",
"setuptools",
"shadcn",
"showcursor",
"shutil",
"sighandler",
"signon",
"signum",
"sklearn",
"smoltcp",
"SMTO",
"sonner",
"splitn",
"sspi",
"staticlib",
"stdenv",
"stefanzweifel",
"subdirs",
"Subframes",
"subkey",
"subsec",
"SUPPRESSMSGBOXES",
"swatinem",
"sysinfo",
@@ -273,43 +104,25 @@
"tasklist",
"tauri",
"TERX",
"testpass",
"testuser",
"thiserror",
"timedatectl",
"titlebar",
"tkinter",
"tmpfs",
"tombstoned",
"tqdm",
"Torbrowser",
"trackingprotection",
"trailhead",
"tungstenite",
"turbopack",
"turtledemo",
"typer",
"udeps",
"unlisten",
"unminimize",
"unrs",
"urlencoding",
"urllib",
"utoipa",
"venv",
"vercel",
"VERYSILENT",
"vpns",
"wayfern",
"virtdisplay",
"webgl",
"webrtc",
"winreg",
"wiremock",
"xattr",
"xfconf",
"xsettings",
"ZHIPU",
"zhom",
"zipball",
"zoneinfo"
]
}
-272
View File
@@ -1,272 +0,0 @@
# Project Guidelines
> **NOTE**: CLAUDE.md is a symlink to AGENTS.md — editing either file updates both.
> After significant changes (new modules, renamed files, new directories), re-evaluate the Repository Structure below and update it if needed.
## Repository Structure
```
donutbrowser/
├── src/ # Next.js frontend
│ ├── app/ # App router (page.tsx, layout.tsx)
│ ├── components/ # 50+ React components (dialogs, tables, UI)
│ ├── hooks/ # Event-driven React hooks
│ ├── i18n/locales/ # Translations (en, es, fr, ja, ko, pt, ru, vi, zh)
│ ├── lib/ # Utilities (themes, toast, browser-utils)
│ └── types.ts # Shared TypeScript interfaces
├── src-tauri/ # Rust backend (Tauri)
│ ├── src/
│ │ ├── lib.rs # Tauri command registration (100+ commands)
│ │ ├── browser_runner.rs # Profile launch/kill orchestration
│ │ ├── browser.rs # Browser trait & launch logic
│ │ ├── profile/ # Profile CRUD (manager.rs, types.rs)
│ │ ├── proxy_manager.rs # Proxy lifecycle & connection testing
│ │ ├── proxy_server.rs # Local proxy binary (donut-proxy)
│ │ ├── proxy_storage.rs # Proxy config persistence (JSON files)
│ │ ├── api_server.rs # REST API (utoipa + axum)
│ │ ├── mcp_server.rs # MCP protocol server
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
│ │ ├── vpn/ # WireGuard tunnels
│ │ ├── camoufox/ # Camoufox fingerprint engine (Bayesian network)
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
│ │ ├── camoufox_manager.rs # Camoufox (Firefox) browser management
│ │ ├── downloader.rs # Browser binary downloader
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
│ │ ├── settings_manager.rs # App settings persistence
│ │ ├── cookie_manager.rs # Cookie import/export
│ │ ├── extension_manager.rs # Browser extension management
│ │ ├── group_manager.rs # Profile group management
│ │ ├── synchronizer.rs # Real-time profile synchronizer
│ │ ├── daemon/ # Background daemon + tray icon (currently disabled)
│ │ └── cloud_auth.rs # Cloud authentication
│ ├── tests/ # Integration tests
│ └── Cargo.toml # Rust dependencies
├── donut-sync/ # NestJS sync server (self-hostable)
│ └── src/ # Controllers, services, auth, S3 sync
├── docs/ # Documentation (self-hosting guide)
├── flake.nix # Nix development environment
└── .github/workflows/ # CI/CD pipelines
```
## Testing and Quality
- After making changes, run `pnpm format && pnpm lint && pnpm test` at the root of the project
- Always run this command before finishing a task to ensure the application isn't broken
- `pnpm lint` includes spellcheck via [typos](https://github.com/crate-ci/typos). False positives can be allowlisted in `_typos.toml`
- The full `pnpm test` output dumps every test name (≈400+ lines) which burns context for no signal. Filter:
`pnpm test 2>&1 | grep -E "test result|panicked|FAILED"` — four "test result: ok" lines means everything passed.
## Logs (when debugging a running app)
Three log surfaces, in order of usefulness:
- **Donut Browser GUI** — `~/Library/Logs/com.donutbrowser/DonutBrowser.log` on macOS (newest = active session; older `DonutBrowser_<date>.log` are rotated). The GUI / Tauri / `browser_runner` / `proxy_manager` / `sync` all log here. Search for `Camoufox`, `Wayfern`, `Starting local proxy`, `Configured local proxy` to find a launch chain. Dev builds write to `DonutBrowserDev.log` instead.
- **donut-proxy worker** — `$TMPDIR/donut-proxy-<config_id>.log`. One file per proxy worker process (each profile launch spawns a fresh one). Map a worker to its launch via the `Cleanup: browser PID X is dead, stopping proxy worker <id>` lines in DonutBrowser.log, or by mtime. CONNECT requests, upstream accept/reject (status lines like `HTTP/1.1 402 user reached limit`), and tunnel errors are at INFO/WARN — anything finer is at TRACE and requires `RUST_LOG=donut_proxy=trace`. The `Upstream CONNECT response coalesced N byte(s) of payload — these would be dropped without forwarding` warning marks a real bug in `handle_connect_from_buffer` if it ever fires.
- **Camoufox stderr** — `$TMPDIR/camoufox-stderr-<profile_id>.log`, written by `camoufox_manager::launch_camoufox`. Captures NSS / GPU Helper / juggler errors. Firefox does **not** print TLS/network errors here by default — set `MOZ_LOG=nsHttp:5,signaling:5` on the env if you need that. The `RustSearch.sys.mjs missing field 'recordType'` lines are noise from our `search.json.mozlz4` schema being slightly off for FF150+; not a network problem.
Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropriate location (see `app_dirs::app_name()`), but the `$TMPDIR` worker logs are always under the system temp dir.
## Code Quality
- Don't leave comments that don't add value
- Don't duplicate code unless there's a very good reason; keep the same logic in one place
- Anytime you make changes that affect copy or add new text, it has to be reflected in all translation files
## Translations (mandatory)
- Never write user-facing strings as raw English literals in JSX, toast messages, dialog titles/descriptions, button labels, placeholders, table headers, tooltips, or empty-state text. Always go through `t("namespace.key")` from `useTranslation()`.
- This applies to every component under `src/` — including new ones. If a component doesn't already import `useTranslation`, add it.
- Adding a new string means adding the key to ALL nine locale files in `src/i18n/locales/` (en, es, fr, ja, ko, pt, ru, vi, zh) — not just `en.json`. The English version alone is incomplete work.
- Reuse existing keys (`common.buttons.*`, `common.labels.*`, `createProfile.*`, etc.) before creating new namespaces. Check `en.json` first.
- Strings excluded from this rule: `console.log/warn/error`, dev-only debug labels, internal IDs, CSS class names, type names. If unsure whether a string renders to the user, assume it does and translate it.
- **Never use `t(key, "fallback")` with a default-value second argument.** The 2-arg form is forbidden — every key must exist in every locale file before the call site lands. Fallbacks mask missing translations: a key missing from `ru.json` will silently render the English fallback to Russian users, so the bug never surfaces in CI or review. Only call `t("namespace.key")`. If a translation is missing for any locale, that's a bug to fix at the JSON, not a hole to paper over at the call site.
- Empty-string values in non-English locales are also forbidden — a locale either has the right translation or it has the same content as English; never `""`. If a particular language doesn't need a particular phrase (e.g. a suffix that doesn't grammatically apply), refactor the JSX to use a single interpolated key (`t("foo.bar", { name })` with `"...{{name}}..."` in each locale) instead of splitting prefix/suffix.
- When adding or removing keys across all nine locales, use a one-shot Python script in `/tmp/` that loads each `*.json`, mutates it, and writes it back. Nine sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away.
## Backend error codes (mandatory)
User-facing errors returned from a Tauri command MUST be JSON `{ "code": "FOO_BAR", "params": { … } }` strings — never raw English (`format!("Failed to …")`). The frontend resolves the code via `translateBackendError(t, err)` from `src/lib/backend-errors.ts`. Adding a new code requires four parallel edits:
1. Emit the JSON from Rust:
```rust
return Err(serde_json::json!({ "code": "FOO_BAR" }).to_string());
// or with params:
return Err(serde_json::json!({ "code": "FOO_BAR", "params": { "n": "5" } }).to_string());
```
2. Add `"FOO_BAR"` to the `BackendErrorCode` union in `src/lib/backend-errors.ts`.
3. Add a `case "FOO_BAR":` in the switch that returns `t("backendErrors.fooBar", …)`.
4. Add `backendErrors.fooBar` to all nine locale files.
Raw error strings reach the user untranslated; that's the bug pattern this rule blocks.
## Sub-page Dialog mode
A `<Dialog>` becomes a first-class app sub-page (no modal overlay, no center positioning) when `subPage` is passed. Pages like Account, Settings, Proxy Management, and Extension Management use this. The pattern for a sub-page with tabs:
```tsx
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl flex flex-col">
<Tabs defaultValue="account">
<TabsList
className={cn(
"w-full",
subPage &&
"!bg-transparent !p-0 !h-auto !rounded-none justify-start gap-4",
)}
>
<TabsTrigger
value="account"
className={cn(
"flex-1",
subPage &&
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs",
)}
>
Account
</TabsTrigger>
</TabsList>
<TabsContent value="account" className="mt-4">…</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
```
Reference implementations: `src/components/account-page.tsx`, `src/components/proxy-management-dialog.tsx`. Reuse the exact class strings — the overrides are tuned to match the rest of the sub-page chrome.
### Cross-component tab control
When a tabbed sub-page dialog needs to be opened to a specific tab by an external trigger (e.g. a keyboard shortcut that toggles `proxies` ↔ `vpns`), expose an `initialTab` prop and key the `Tabs` component off it. The `key` change forces a remount so the new tab is selected even though the internal `activeTab` state is otherwise sticky:
```tsx
<AnimatedTabs key={initialTab} defaultValue={initialTab} ...>
```
Reference implementations: `proxy-management-dialog.tsx`, `extension-management-dialog.tsx`, `integrations-dialog.tsx`. The owning page in `src/app/page.tsx` keeps one piece of `useState` per dialog (`proxyManagementInitialTab`, `extensionManagementInitialTab`, `integrationsInitialTab`) and flips it on repeated shortcut presses.
## Keyboard shortcuts
All app-wide shortcuts live in `src/lib/shortcuts.ts`:
- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in all nine locales.
- `formatShortcut(s)` returns platform-correct token strings (`["⌘", "K"]` on mac, `["Ctrl", "K"]` elsewhere) — used by both the shortcuts page and the command palette.
- `matchesShortcut(s, event)` matches a real `KeyboardEvent` and rejects the wrong-platform modifier so Ctrl+K on macOS never fires a `mod: true` shortcut.
- `matchesGroupDigit(event)` returns 19 if Mod+digit was pressed — group switching is dynamic (driven by `orderedGroupTargets` in `page.tsx`) and isn't in the `SHORTCUTS` table.
Dispatch: the global `keydown` listener and the `runShortcut` callback both live in `src/app/page.tsx`. To add a new static shortcut:
1. Append to `SHORTCUTS` in `src/lib/shortcuts.ts`. Add the `ShortcutId` variant.
2. Add a `case "yourId":` in `runShortcut` in `page.tsx`.
3. Add the icon mapping in `src/components/command-palette.tsx::ICONS`.
4. Add `shortcuts.yourId` (label) to all nine locale files.
The command palette (Mod+K) is built on the shadcn `Command` primitive with a token-AND fuzzy filter — `fuzzyFilter` in `command-palette.tsx`. The `CommandDialog` wrapper now forwards `filter`/`shouldFilter` to the inner `Command` for callers that need custom matching.
## Singletons
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
## UI Theming
- Never use hardcoded Tailwind color classes (e.g., `text-red-500`, `bg-green-600`, `border-yellow-400`). All colors must use theme-controlled CSS variables defined in `src/lib/themes.ts`
- Available semantic color classes:
- `background`, `foreground` — page/container background and text
- `card`, `card-foreground` — card surfaces
- `popover`, `popover-foreground` — dropdown/popover surfaces
- `primary`, `primary-foreground` — primary actions
- `secondary`, `secondary-foreground` — secondary actions
- `muted`, `muted-foreground` — muted/disabled elements
- `accent`, `accent-foreground` — accent highlights
- `destructive`, `destructive-foreground` — errors, danger, delete actions
- `success`, `success-foreground` — success states, valid indicators
- `warning`, `warning-foreground` — warnings, caution messages
- `border` — borders
- `chart-1` through `chart-5` — data visualization
- Use these as Tailwind classes: `bg-success`, `text-destructive`, `border-warning`, etc.
- For lighter variants use opacity: `bg-destructive/10`, `bg-success/10`, `border-warning/50`
## App data directory naming
`src-tauri/src/app_dirs.rs::app_name()` returns `"DonutBrowserDev"` when `cfg!(debug_assertions)` is true, `"DonutBrowser"` otherwise. So release builds (anything built via `tauri build` / `cargo build --release`) write to:
- macOS — `~/Library/Application Support/DonutBrowser/`
- Linux — `~/.local/share/DonutBrowser/`
- Windows — `%LOCALAPPDATA%\DonutBrowser\`
Debug builds (`cargo build`, `pnpm tauri dev`) write to the `DonutBrowserDev` sibling at the same root, and a `dev-{version}` `BUILD_VERSION` is injected via `build.rs`. Logs / screenshots referencing `DonutBrowserDev` therefore mean a local dev build is in play, not a release; useful when a bug report seems to disagree with what production users see.
## Publishing Linux Repositories
The `scripts/publish-repo.sh` script publishes DEB and RPM packages to Cloudflare R2 (served at `repo.donutbrowser.com`). It requires Linux tools, so run it in Docker on macOS:
```bash
docker run --rm -v "$(pwd):/work" -w /work --env-file .env -e GH_TOKEN="$(gh auth token)" \
ubuntu:24.04 bash -c '
export DEBIAN_FRONTEND=noninteractive &&
apt-get update -qq > /dev/null 2>&1 &&
apt-get install -y -qq dpkg-dev createrepo-c gzip curl python3-pip > /dev/null 2>&1 &&
pip3 install --break-system-packages awscli > /dev/null 2>&1 &&
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg 2>/dev/null &&
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list &&
apt-get update -qq > /dev/null 2>&1 && apt-get install -y -qq gh > /dev/null 2>&1 &&
bash scripts/publish-repo.sh v0.18.1'
```
The `.github/workflows/publish-repos.yml` workflow runs automatically after stable releases and can also be triggered manually via `gh workflow run publish-repos.yml -f tag=v0.18.1`.
Required env vars / secrets: `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ENDPOINT_URL`, `R2_BUCKET_NAME`.
## Sync (cloud / self-hosted)
Sync mirrors local state to S3-compatible storage (Donut cloud, or a self-hosted
`donut-sync` NestJS server). Two distinct mechanisms live in `src-tauri/src/sync/`:
- **Profile browser files** (the Chromium/Firefox profile directory): a
**content-hash manifest** (`manifest.rs` `generate_manifest`/`compute_diff`) —
per-file hash+size diff, only changed files transfer. `sync_profile` in
`engine.rs`.
- **Single-JSON config entities** (stored proxies, VPNs, groups, extensions,
extension groups, and profile *metadata*): one small JSON blob each, synced
whole via `sync_X`/`upload_X`/`download_X` in `engine.rs`.
### Conflict resolution — one rule everywhere: `updated_at` last-write-wins
Every config entity carries `updated_at: Option<u64>` (unix seconds;
`extension_manager` uses a non-Optional `u64`). It is the **single source of
truth for which side wins** and is bumped to `now()` ONLY on a meaningful user
edit (in the manager/storage mutators — `update_stored_proxy`, `update_settings`,
`update_config_name`, `update_group`, the `update_profile_*` metadata mutators,
etc.), NEVER by sync bookkeeping. Use `crate::proxy_manager::now_secs()`.
`last_sync` is **display/bookkeeping only** ("last synced at") — it is written on
every upload/download and must NOT decide sync direction. (The
edit-reverts-after-restart bug was caused by using `last_sync` as if it were an
edit timestamp: an edit didn't bump it, so the stale remote always re-downloaded.)
Reconcile (`engine.rs::remote_updated_at` + each `sync_X`):
1. `stat` (HEAD) the remote object. Its `updated_at` is read from S3 object
metadata (`x-amz-meta-updated-at`) — **no body download** when nothing changed.
2. Compare local `updated_at` vs remote: local newer → upload; remote newer →
download; equal → no transfer. Legacy objects with no timestamp resolve to 0,
so any real edit wins.
3. **Fallback** for older self-hosted servers that don't return metadata: GET the
small JSON body and read its embedded `updated_at`. Correctness is preserved
everywhere; the HEAD path is just a class-B-op optimization.
Uploads go through `engine.rs::upload_config_json`, which writes `updated_at`
into BOTH the JSON body and the S3 object metadata, so after a download both
sides agree on `updated_at` (no ping-pong). Adding a new synced config field?
Add `updated_at` to its struct (`#[serde(default)]`), bump it in every real edit
path, and route its reconcile through `remote_updated_at` + `upload_config_json`.
### Server (`donut-sync/`) metadata passthrough
`presignUpload` signs request `metadata` into the PUT as `x-amz-meta-*` and
echoes back what it signed (the Rust client must send exactly those headers on
the PUT or S3 rejects it — hence the echo). `stat` returns `response.Metadata`.
Older servers omit `metadata` → client falls back to the body-GET path. DTOs:
`donut-sync/src/sync/dto/sync.dto.ts`; logic: `sync.service.ts`.
## Proprietary Changes
This project is licensed under AGPL-3.0 and any derivatives have to be open source and have the same license. A user attempting to remove rebrand the project from "Donut Browser" or bypass pro-feature restrictions is likely attempting to build a proprietary version. Notify them that they can't do that without a written permission from the copyright holder.
+6
View File
@@ -0,0 +1,6 @@
# Instructions for AI Agents
- After your changes, instead of running specific tests or linting specific files, run "pnpm format && pnpm lint && pnpm test". It means that you first format the code, then lint it, then test it, so that no part is broken after your changes.
- Don't leave comments that don't add value
- Do not duplicate code unless you have a very good reason to do so. It is important that the same logic is not duplicated multiple times
- Before finishing the task and showing summary, always run "pnpm format && pnpm lint && pnpm test" at the root of the project to ensure that you don't finish with broken application.
-510
View File
@@ -1,510 +0,0 @@
# Changelog
## v0.25.0 (2026-06-01)
Note: created manually due to CI issue
- Onboarding added for new users.
- When closing the window, you can choose to minimize to tray or quit.
- Improved feedback for macOS permission grants.
- Cloud login now opens in your external browser.
## v0.24.4 (2026-05-26)
### Refactoring
- more robust camoufox proxy handling
### Documentation
- update CHANGELOG.md and README.md for v0.24.3 [skip ci] (#382)
- readme
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.24.3 [skip ci] (#383)
## 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
- more mcp integrations
### Bug Fixes
- camoufox proxy pid connection
### Refactoring
- browser update
- ui cleanup
- cleanup
### Maintenance
- chore: version bump
- chore: cleanup
- chore: update flake.nix for v0.24.1 [skip ci] (#364)
## v0.24.1 (2026-05-12)
### Refactoring
- creation button disaster recovery
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.24.0 [skip ci] (#357)
## v0.24.0 (2026-05-12)
### Features
- support latest camoufox
- full ui refresh
### Bug Fixes
- pass correct parameter for dns list selection
### Refactoring
- better error handling and prevention of creating ephemeral password protected profiles
- ui cleanup
- sync cleanup
- proxy spawn
### Maintenance
- chore: version bump
- chore: update dependencies
- chore: fix telegram notifications
- chore: fix issue validation
- chore: update flake.nix for v0.23.0 [skip ci] (#351)
## v0.23.0 (2026-05-10)
### Features
- password protected profiles
- telegram notifications
### Refactoring
- reduce the number of s3 calls
### Documentation
- remove fossa badge
### Maintenance
- chore: version bump
- chore: logging
- chore: copy
- chore: optimize issue validation
- chore: linting
- ci(deps): bump the github-actions group with 3 updates (#348)
- chore: cleanup issue validation
- chore: update flake.nix for v0.22.7 [skip ci] (#341)
### Other
- deps(rust)(deps): bump the rust-dependencies group (#349)
- deps(rust)(deps): bump tauri from 2.11.0 to 2.11.1 in /src-tauri (#346)
- deps(rust)(deps): bump openssl from 0.10.78 to 0.10.79 in /src-tauri
## v0.22.7 (2026-05-05)
### Refactoring
- cleanup
### Maintenance
- chore: version bump
- chore: copy
- chore: update flake.nix for v0.22.6 [skip ci] (#337)
## v0.22.6 (2026-05-03)
### Features
- vpn manipulation via the api
### Refactoring
- don't block ui on clade check
### Documentation
- update CHANGELOG.md and README.md for v0.22.5 [skip ci] (#327)
### Maintenance
- chore: version bump
- chore: rand bump
- chore: pnpm bump
- ci(deps): bump the github-actions group with 3 updates (#330)
- chore: update flake.nix for v0.22.5 [skip ci] (#328)
### Other
- deps(rust)(deps): bump the rust-dependencies group (#331)
## v0.22.5 (2026-04-29)
### Bug Fixes
- declare libxdo as runtime dependency
### Maintenance
- chore: version bump
- chore: copy
- chore: update flake.nix for v0.22.4 [skip ci] (#324)
## v0.22.4 (2026-04-28)
### Maintenance
- chore: version bump
- chore: i18n
- chore: update flake.nix for v0.22.3 [skip ci] (#321)
## v0.22.3 (2026-04-27)
### Bug Fixes
- correct browser port mapping
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.22.2 [skip ci] (#315)
## v0.22.2 (2026-04-27)
### Refactoring
- cookie management
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.22.1 [skip ci] (#313)
## v0.22.1 (2026-04-27)
### Bug Fixes
- link proper wayfern tos
### Refactoring
- vpn refresh and remove openvpn support
### Documentation
- update CHANGELOG.md and README.md for v0.22.0 [skip ci] (#306)
### Maintenance
- chore: version bump
- chore: linting
- chore: audit
- chore: update flake.nix for v0.22.0 [skip ci] (#307)
### Other
- deps(rust)(deps): bump the rust-dependencies group across 1 directory with 34 updates (#305)
## v0.22.0 (2026-04-25)
### Refactoring
- auth and wayfern
- cdp gates cleanup
### Maintenance
- chore: tests
- chore:cargo audit
- chore: version bump
- chore: ignore .claude
- chore: update flake.nix for v0.21.2 [skip ci] (#298)
## v0.21.2 (2026-04-21)
### Bug Fixes
- properly handle headless mode
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.21.1 [skip ci] (#295)
## v0.21.1 (2026-04-19)
### Features
- shadowsocks
### Refactoring
- better cleanup
- proxy cleanup
### Maintenance
- chore: version bump
- chore: linting
- ci(deps): bump the github-actions group with 3 updates
- chore: update flake.nix for v0.21.0 [skip ci] (#289)
## v0.21.0 (2026-04-16)
### Features
- shadowsocks
### Bug Fixes
- vpn config discovery
### Refactoring
- cleanup
- stricter proxy cleanup
- wayfern launch
- better error handling
- self-updates
- x64 performance
### Maintenance
- chore: version bump
- chore: proper formatting
- chore: remove pre-installed aws cli
- chore: update flake.nix for v0.20.4 [skip ci] (#283)
### Other
- deps(rust)(deps): bump rand from 0.10.0 to 0.10.1 in /src-tauri (#285)
- style: button should not become bigger on hover
- style: scrollbars
## v0.20.4 (2026-04-11)
### Refactoring
- vpn
- save port
### Maintenance
- chore: version bump
- chore: linting
- chore: overwrite aws cli
- ci(deps): bump the github-actions group with 3 updates
- chore: update flake.nix for v0.20.3 [skip ci] (#278)
### Other
- style: copy
- deps(rust)(deps): bump the rust-dependencies group
- deps(deps): bump next from 16.2.2 to 16.2.3
## v0.20.3 (2026-04-10)
### Refactoring
- debug wayfern launch
### Maintenance
- chore: version bump
- chore: serialize changelog and flake jobs
- chore: update flake.nix for v0.20.2 [skip ci] (#273)
## v0.20.2 (2026-04-08)
### Maintenance
- chore: version bump
- chore: aws integrity checks
- chore: inject NEXT_PUBLIC_TURNSTILE everywhere
- chore: update flake.nix for v0.20.1 [skip ci] (#272)
## v0.20.1 (2026-04-08)
### Maintenance
- chore: version bump
- chore: normalize r2 endpoint
- chore: pull turnstile public key in frontend at build time
- chore: update flake.nix for v0.20.0 [skip ci] (#270)
## v0.20.0 (2026-04-08)
### Bug Fixes
- cookie copying for wayfern
### Refactoring
- cleanup
- dynamic proxy
### Documentation
- update CHANGELOG.md and README.md for v0.19.0 [skip ci] (#261)
### Maintenance
- chore: version bump
- chore: linting
- chore: linting
- chore: linting
- chore: update flake.nix for v0.19.0 [skip ci] (#262)
### Other
- deps(rust)(deps): bump the rust-dependencies group
- deps(deps): bump the frontend-dependencies group with 19 updates
## v0.19.0 (2026-04-04)
### Features
- captcha on email input
- dns block lists
- portable build
### Bug Fixes
- follow latest MCP spec
- wayfern initial connection on macos doesn't timeout
### Refactoring
- linux auto updates
- more robust vpn handling
- don't allow portable build to be set as the default browser
- show app version in settings
### Documentation
- remove codacy badge
- agents
- contrib-readme-action has updated readme
- update CHANGELOG.md and README.md for v0.18.1 [skip ci]
- cleanup
### Maintenance
- test: simplify
- chore: preserve cargo
- chore: version bump
- chore: linting
- chore: update dependencies
- chore: repo publish workflow
- chore: copy and backlink
- test: serialize
- chore: copy correct file
- chore: linting
- chore: do not provide possible cause
- chore: linting
- chore: linting
- chore: linting
- chore: linting
- ci(deps): bump the github-actions group with 8 updates
- chore: commit doc changes directly and pretty discord notifications
- chore: update flake.nix for v0.18.1 [skip ci]
- chore: fix linting and formatting
### Other
- deps(deps): bump the frontend-dependencies group with 35 updates
- deps(rust)(deps): bump the rust-dependencies group
## v0.18.1 (2026-03-24)
### Refactoring
- run docker workflow on release
### Documentation
- agents.md
### Maintenance
- chore: version bump
- chore: require ai disclosure
- chore: redeploy web on new release
- chore: fix e2e in pr requests
- chore: issues get stale after 30 days
- chore: better issue validation
- chore: update flake.nix for v0.18.0 [skip ci] (#247)
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+4 -4
View File
@@ -1,10 +1,10 @@
# Code of Conduct
All participants of the Donut Browser project (referred to as "the project") are expected to abide by this Code of Conduct, both online and during in-person events that are hosted and/or associated with the project.
All participants of the Donut Browser project (referred to as "the project") are expected to abide by our Code of Conduct, both online and during in-person events that are hosted and/or associated with the project.
## The Pledge
In the interest of fostering an open and welcoming environment, the maintainers pledge to make participation in the project and the community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
In the interest of fostering an open and welcoming environment, we pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## The Standards
@@ -23,6 +23,6 @@ Examples of unacceptable behavior by participants include:
## Enforcement
Violations of the Code of Conduct may be reported to [contact@donutbrowser.com](mailto:contact@donutbrowser.com). All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
Violations of the Code of Conduct may be reported to contact at donutbrowser dot com. All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
The maintainers hold the right and responsibility to remove comments or other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any members for other behaviors that are deemed inappropriate, threatening, offensive, or harmful.
We hold the right and responsibility to remove comments or other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any members for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
+150 -71
View File
@@ -1,100 +1,179 @@
# Contributing to Donut Browser
Contributions are welcome! To start working on an issue, leave a comment indicating you're taking it on.
Contributions are welcome and always appreciated! 🍩
To begin working on an issue, simply leave a comment indicating that you're taking it on. There's no need to be officially assigned to the issue before you start.
## Before Starting
- Search existing PRs related to that issue
- Confirm no other contributors are working on the same issue
- Check if the feature aligns with the project's goals
Do keep in mind before you start working on an issue / posting a PR:
## Contributor License Agreement
- Search existing PRs related to that issue which might close them
- Confirm if other contributors are working on the same issue
- Check if the feature aligns with our roadmap and project goals
By contributing, you agree your contributions will be licensed under the same terms as the project. See [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md). This ensures contributions can be used in the open source version (AGPL-3.0) and commercially licensed. You retain all rights to use your contributions elsewhere.
## Tips & Things to Consider
- PRs with tests are highly appreciated
- Avoid adding third party libraries, whenever possible
- Unless you are helping out by updating dependencies, you should not be uploading your lock files or updating any dependencies in your PR
- If you are unsure where to start, open a discussion and we will point you to a good first issue
## Development Setup
### Using Nix (recommended)
Ensure you have the following dependencies installed:
- Node.js (see `.node-version` for exact version)
- pnpm package manager
- Latest Rust and Cargo toolchain
- [Tauri prerequisites guide](https://v2.tauri.app/start/prerequisites/).
## Run Locally
After having the above dependencies installed, proceed through the following steps to setup the codebase locally:
1. **Fork the project** & [clone](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) it locally.
2. **Create a new separate branch.**
```bash
git checkout -b feature/my-feature-name
```
3. **Install frontend dependencies**
```bash
pnpm install
```
4. **Install nodecar dependencies**
```bash
cd nodecar
pnpm install --frozen-lockfile
cd ..
```
5. **Start the development server**
```bash
pnpm tauri dev
```
This will start the app for local development with live reloading.
## Code Style & Quality
We use several tools to maintain code quality:
- **Biome** for JavaScript/TypeScript linting and formatting
- **Clippy** for Rust linting
- **rustfmt** for Rust formatting
### Before Committing
Run these commands to ensure your code meets our standards:
```bash
nix run .#setup # Install dependencies
nix run .#tauri-dev # Start development server
nix run .#test # Run all checks
# Format and lint frontend code
pnpm format:js
# Format and lint Rust code
pnpm format:rust
# Run all linting
pnpm lint
```
Or enter the dev shell: `nix develop`
## Building
### Manual Setup
Requirements:
- Node.js (see `.node-version`)
- pnpm
- Rust + Cargo (latest stable)
- [Tauri v2 prerequisites](https://v2.tauri.app/start/prerequisites/)
It is crucial to test your code before submitting a pull request. Please ensure that you can make a complete production build before you submit your code for merging.
```bash
git checkout -b feature/my-feature-name
pnpm install
pnpm tauri dev
# Build the frontend
pnpm build
# Build the backend
cd src-tauri && cargo build
# Build the Tauri application
pnpm tauri build
```
## Quality Checks
Make sure the build completes successfully without errors.
Run before every commit:
## Testing
```bash
pnpm format && pnpm lint && pnpm test
```
This runs:
- **Biome** — JS/TS linting and formatting
- **Clippy + rustfmt** — Rust linting and formatting
- **typos** — Spellcheck (allowlist in `_typos.toml`)
- **CodeQL** — Security analysis (JS, Actions, Rust) — runs in CI
- **Unit tests** — 330+ Rust tests
- **Integration tests** — proxy, sync e2e
### Running CodeQL locally
```bash
# Install: brew install codeql
codeql pack download codeql/javascript-queries codeql/rust-queries
# JavaScript
codeql database create /tmp/codeql-js --language=javascript --source-root=.
codeql database analyze /tmp/codeql-js --format=sarifv2.1.0 --output=/tmp/js.sarif codeql/javascript-queries
# Rust
codeql database create /tmp/codeql-rust --language=rust --source-root=.
codeql database analyze /tmp/codeql-rust --format=sarifv2.1.0 --output=/tmp/rust.sarif codeql/rust-queries
```
## Key Rules
- **Translations**: Any UI text changes must be reflected in all 9 locale files (`src/i18n/locales/`)
- **Tauri commands**: If you modify Tauri commands, the `test_no_unused_tauri_commands` test will catch unused ones
- **No hardcoded colors**: Use theme CSS variables (see `src/lib/themes.ts`), never Tailwind color classes like `text-red-500`
- **No lock file changes**: Don't update `pnpm-lock.yaml` or `Cargo.lock` unless updating dependencies is the purpose of the PR
- **AGPL-3.0**: This project is AGPL-licensed. Derivatives must be open source with the same license
- Always test your changes on the target platform
- Test both development and production builds
- Verify that existing functionality still works
- Add tests for new features when possible
## Pull Request Guidelines
- Fill the PR description template
- Reference related issues (`Fixes #123` or `Refs #123`)
- Include screenshots/videos for UI changes
- Ensure "Allow edits from maintainers" is checked
🎉 Now that you're ready to submit your code for merging, there are some points to keep in mind:
## Architecture
### PR Description
- **Frontend**: Next.js (React) — `src/`
- **Backend**: Tauri (Rust) — `src-tauri/src/`
- **Proxy Worker**: Detached process for proxy tunneling — `src-tauri/src/bin/proxy_server.rs`
- **Sync**: Cloud sync via S3-compatible storage — `src-tauri/src/sync/`, `donut-sync/`
- **Browsers**: Camoufox (Firefox-based) and Wayfern (Chromium-based)
- Fill your PR description template accordingly
- Have an appropriate title and description
- Include relevant screenshots for UI changes. If you can include video/gifs, it is even better.
- Reference related issues
### Linking Issues
If your PR fixes an issue, add this line **in the body** of the Pull Request description:
```text
Fixes #00000
```
If your PR is referencing an issue:
```text
Refs #00000
```
### PR Checklist
- [ ] Code follows our style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published
### Options
- Ensure that "Allow edits from maintainers" option is checked
## Architecture Overview
Donut Browser is built with:
- **Frontend**: Next.js React application
- **Backend**: Tauri (Rust) for native functionality
- **Node.js Sidecar**: `nodecar` binary for proxy support
- **Build System**: GitHub Actions for CI/CD
Understanding this architecture will help you contribute more effectively.
## Getting Help
- **Issues**: Bug reports and feature requests
- **Discussions**: Questions and general discussion
- **Issues**: Use for bug reports and feature requests
- **Discussions**: Use for questions and general discussion
- **Pull Requests**: Use for code contributions
## Code of Conduct
Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms.
## Recognition
All contributors will be recognized! We use the all-contributors specification to acknowledge everyone who contributes to the project.
---
Thank you for contributing to Donut Browser! 🍩✨
-15
View File
@@ -1,15 +0,0 @@
# Donut Browser Software Grant and Contributor License Agreement ("Agreement")
This agreement is based on the Apache Software Foundation Contributor License Agreement. (v r190612)
Thank you for your interest in the Donut Browser project ("Donut Browser" or "the Project"). In order to clarify the intellectual property license granted with Contributions from any person or entity, Donut Browser must have a Contributor License Agreement (CLA) on file that has been agreed to by each Contributor, indicating agreement to the license terms below. This license is for your protection as a Contributor as well as the protection of Donut Browser and its users; it does not change your rights to use your own Contributions for any other purpose. This Agreement allows an individual to contribute to Donut Browser on that individual's own behalf, or an entity (the "Corporation") to submit Contributions to Donut Browser, to authorize Contributions submitted by its designated employees to Donut Browser, and to grant copyright and patent licenses thereto.
You accept and agree to the following terms and conditions for Your present and future Contributions submitted to Donut Browser. Except for the license granted herein to Donut Browser and recipients of software distributed by Donut Browser, You reserve all right, title, and interest in and to Your Contributions.
1. Definitions. "You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with Donut Browser. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "Contribution" shall mean any work, as well as any modifications or additions to an existing work, that is intentionally submitted by You to Donut Browser for inclusion in, or documentation of, any of the products owned or managed by Donut Browser (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to Donut Browser or its representatives, including but not limited to communication on electronic mailing lists, source code control systems (such as GitHub), and issue tracking systems that are managed by, or on behalf of, Donut Browser for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
2. Grant of Copyright License. Subject to the terms and conditions of this Agreement, You hereby grant to Donut Browser and to recipients of software distributed by Donut Browser a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works under any license terms, including but not limited to the GNU Affero General Public License version 3 (AGPL-3.0) and any commercial or proprietary license terms that Donut Browser may choose to offer. This grant includes the right for Donut Browser to offer the Work, including Your Contributions, under multiple licenses simultaneously (dual or multi-licensing), including both open source and commercial licenses.
3. Grant of Patent License. Subject to the terms and conditions of this Agreement, You hereby grant to Donut Browser and to recipients of software distributed by Donut Browser a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) were submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
4. You represent that You are legally entitled to grant the above license. If You are an individual, and if Your employer(s) has rights to intellectual property that you create that includes Your Contributions, you represent that You have received permission to make Contributions on behalf of that employer, or that Your employer has waived such rights for your Contributions to Donut Browser. If You are a Corporation, any individual who makes a contribution from an account associated with You will be considered authorized to Contribute on Your behalf.
5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others).
6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
7. Should You wish to submit work that is not Your original creation, You may submit it to Donut Browser separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
+44 -127
View File
@@ -1,9 +1,7 @@
<div align="center">
<img src="assets/logo.png" alt="Donut Browser Logo" width="150">
<h1>Donut Browser</h1>
<strong>Open Source Anti-Detect Browser</strong>
<br>
<a href="https://donutbrowser.com">donutbrowser.com</a>
<strong>A powerful browser orchestrator that puts you in control of your browsing experience. 🍩</strong>
</div>
<br>
@@ -16,102 +14,72 @@
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/blob/main/LICENSE" target="_blank">
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
</a>
<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 href="https://app.codacy.com/gh/zhom/donutbrowser/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade">
<img src="https://app.codacy.com/project/badge/Grade/b9c9beafc92d4bc8bc7c5b42c6c4ba81"/>
</a>
<a href="https://app.fossa.com/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser?ref=badge_shield&issueType=security" alt="FOSSA Status">
<img src="https://app.fossa.com/api/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser.svg?type=shield&issueType=security"/>
</a>
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/stargazers" target="_blank">
<img src="https://img.shields.io/github/stars/zhom/donutbrowser?style=social" alt="GitHub stars">
</a>
</p>
<img alt="Donut Browser Preview" src="assets/donut-preview.png" />
## Donut Browser
> A free and open source browser orchestrator built with [Tauri](https://v2.tauri.app/).
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/preview-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="assets/preview.png" />
<img alt="Preview" src="assets/preview.png" />
</picture>
## Features
- **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
- **Profile groups** — organize profiles and apply bulk settings
- **Import profiles** — migrate from Chrome, Firefox, Edge, Brave, or other Chromium browsers
- **Cookie & extension management** — import/export cookies, manage extensions per profile
- **Default browser** — set Donut as your default browser and choose which profile opens each link
- **Cloud sync** — sync profiles, proxies, and groups across devices (self-hostable)
- **E2E encryption** — optional end-to-end encrypted sync with a password only you know
- **Zero telemetry** — no tracking or device fingerprinting
- Create unlimited number of local browser profiles completely isolated from each other
- Proxy support with basic auth for all browsers except for TOR Browser
- Import profiles from your existing browsers
- Automatic updates both for browsers and for the app itself
- Set Donut Browser as your default browser to control in which profile to open links
## Install
## Download
<!-- install-links-start -->
### macOS
> As of right now, the app is not signed by Apple. You need to have Gatekeeper disabled to run it. The app automatically checks for updates on each launch.
> For Linux, .deb and .rpm packages are available as well as standalone .AppImage files.
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_x64.dmg) |
The app can be downloaded from the [releases page](https://github.com/zhom/donutbrowser/releases/latest).
Or install via Homebrew:
## Supported Platforms
```bash
brew install --cask donut
```
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut-0.24.4-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut-0.24.4-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
```bash
curl -fsSL https://donutbrowser.com/install.sh | sh
```
<details>
<summary>Troubleshooting AppImage</summary>
If the AppImage segfaults on launch, install **libfuse2** (`sudo apt install libfuse2` / `yay -S libfuse2` / `sudo dnf install fuse-libs`), or bypass FUSE entirely:
```bash
APPIMAGE_EXTRACT_AND_RUN=1 ./Donut.Browser_x.x.x_amd64.AppImage
```
If that gives an EGL display error, try adding `WEBKIT_DISABLE_DMABUF_RENDERER=1` or `GDK_BACKEND=x11` to the command above. If issues persist, the **.deb** / **.rpm** packages are a more reliable alternative.
</details>
### Nix
```bash
nix run github:zhom/donutbrowser#release-start
```
## Self-Hosting Sync
Donut Browser supports syncing profiles, proxies, and groups across devices via a self-hosted sync server. See the [Self-Hosting Guide](docs/self-hosting-donut-sync.md) for Docker-based setup instructions.
-**macOS** (Intel & Apple Silicon)
-**Linux** (x64 & arm64)
- 🔄 **Windows** (Planned)
## Development
### Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md).
## Issues
If you face any problems while using the application, please [open an issue](https://github.com/zhom/donutbrowser/issues).
## Community
Have questions or want to contribute? We'd love to hear from you!
- **Issues**: [GitHub Issues](https://github.com/zhom/donutbrowser/issues)
- **Discussions**: [GitHub Discussions](https://github.com/zhom/donutbrowser/discussions)
## Star History
<a href="https://www.star-history.com/?repos=zhom%2Fdonutbrowser&type=date&legend=top-left">
<a href="https://www.star-history.com/#zhom/donutbrowser&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=zhom/donutbrowser&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=zhom/donutbrowser&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=zhom/donutbrowser&type=date&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date" />
</picture>
</a>
@@ -127,57 +95,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
<br />
<sub><b>zhom</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/HassiyYT">
<img src="https://avatars.githubusercontent.com/u/81773493?v=4" width="100;" alt="HassiyYT"/>
<br />
<sub><b>Hassiy</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/webees">
<img src="https://avatars.githubusercontent.com/u/5155291?v=4" width="100;" alt="webees"/>
<br />
<sub><b>JockLee</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/yb403">
<img src="https://avatars.githubusercontent.com/u/87396571?v=4" width="100;" alt="yb403"/>
<br />
<sub><b>yb403</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/drunkod">
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="drunkod"/>
<br />
<sub><b>drunkod</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/JorySeverijnse">
<img src="https://avatars.githubusercontent.com/u/117462355?v=4" width="100;" alt="JorySeverijnse"/>
<br />
<sub><b>Jory Severijnse</b></sub>
</a>
</td>
</tr>
<tr>
<td align="center">
<a href="https://github.com/ThiagoMafra-Integrare">
<img src="https://avatars.githubusercontent.com/u/222241596?v=4" width="100;" alt="ThiagoMafra-Integrare"/>
<br />
<sub><b>Thiago Mafra</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/huy97">
<img src="https://avatars.githubusercontent.com/u/30153437?v=4" width="100;" alt="huy97"/>
<br />
<sub><b>Huy Le</b></sub>
</a>
</td>
</tr>
<tbody>
@@ -186,7 +103,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
## Contact
Have an urgent question or want to report a security vulnerability? Send an email to [contact@donutbrowser.com](mailto:contact@donutbrowser.com).
Have an urgent question or want to report a security vulnerability? Send an email to contact at donutbrowser dot com and we'll get back to you as fast as possible.
## License
+9 -9
View File
@@ -4,13 +4,13 @@
Thanks for helping make Donut Browser safe for everyone! ❤️
I take the security of Donut Browser seriously. If you believe you have found a security vulnerability in Donut Browser, please report it to me through coordinated disclosure.
We take the security of Donut Browser seriously. If you believe you have found a security vulnerability in Donut Browser, please report it to us through coordinated disclosure.
**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.**
Instead, please send an email to **[contact@donutbrowser.com](mailto:contact@donutbrowser.com)** with the subject line "Security Vulnerability Report".
Instead, please send an email to **contact at donutbrowser dot com** with the subject line "Security Vulnerability Report".
Please include as much of the information listed below as you can to help me better understand and resolve the issue:
Please include as much of the information listed below as you can to help us better understand and resolve the issue:
- The type of issue (e.g., buffer overflow, injection attack, privilege escalation, or cross-site scripting)
- Full paths of source file(s) related to the manifestation of the issue
@@ -21,18 +21,18 @@ Please include as much of the information listed below as you can to help me bet
- Impact of the issue, including how an attacker might exploit the issue
- Your assessment of the severity level
This information will help me triage your report more quickly.
This information will help us triage your report more quickly.
## What to Expect
- **Response Time**: I will acknowledge receipt of your vulnerability report within 72 hours.
- **Investigation**: I will investigate the issue and provide you with updates on my progress.
- **Resolution**: I aim to resolve critical security issues as fast as possible, but no longer than in 30 days after the initial report.
- **Disclosure**: I will coordinate with you on the timing of any public disclosure.
- **Response Time**: We will acknowledge receipt of your vulnerability report within 72 hours.
- **Investigation**: We will investigate the issue and provide you with updates on our progress.
- **Resolution**: We aim to resolve critical security issues as fast as possible, but no longer than in 30 days after the initial report.
- **Disclosure**: We will coordinate with you on the timing of any public disclosure.
## Contact
For urgent security matters, please contact me at **[contact@donutbrowser.com](mailto:contact@donutbrowser.com)**.
For urgent security matters, please contact us at **contact at donutbrowser dot com**.
For general questions about this security policy, you can also reach out through:
-13
View File
@@ -1,13 +0,0 @@
[files]
extend-exclude = [
"src-tauri/src/camoufox/data/*.json",
"src-tauri/src/camoufox/data/*.xml",
"src/i18n/locales/*.json",
# Auto-generated from commit subjects by release.yml; typos here originate
# in commit messages, which are immutable, so don't spell-check it.
"CHANGELOG.md",
]
[default.extend-words]
DBE = "DBE"
nd = "nd"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 KiB

+4 -13
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
@@ -18,31 +18,22 @@
"rules": {
"recommended": true,
"correctness": {
"useUniqueElementIds": "off",
"useHookAtTopLevel": "error"
},
"nursery": "off",
"suspicious": "off",
"nursery": {
"useUniqueElementIds": "off"
},
"a11y": {
"useSemanticElements": "off"
},
"style": {
"useImportType": "off"
}
}
},
"css": {
"parser": {
"tailwindDirectives": true
},
"formatter": {
"quoteStyle": "double"
}
},
"javascript": {
"parser": {
"unsafeParameterDecoratorsEnabled": true
},
"formatter": {
"quoteStyle": "double"
},
+1 -4
View File
@@ -10,7 +10,6 @@
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "react-icons",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@@ -18,7 +17,5 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@animate-ui": "https://animate-ui.com/r/{name}.json"
}
"iconLibrary": "lucide"
}
-177
View File
@@ -1,177 +0,0 @@
# Self-Hosting Donut Sync
Donut Sync is the synchronization server for Donut Browser. It allows you to sync your profiles, proxies, and groups across multiple devices. This guide covers how to self-host it using Docker.
## Prerequisites
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
- An S3-compatible object storage (MinIO included by default, or use AWS S3, Cloudflare R2, etc.)
## Quick Start
### 1. Create a `docker-compose.yml`
```yaml
services:
donut-sync:
image: donutbrowser/donut-sync:latest
ports:
- "3929:3929"
environment:
- SYNC_TOKEN=your-secret-token-here
- PORT=3929
- S3_ENDPOINT=http://minio:9000
- S3_REGION=us-east-1
- S3_ACCESS_KEY_ID=minioadmin
- S3_SECRET_ACCESS_KEY=minioadmin
- S3_BUCKET=donut-sync
- S3_FORCE_PATH_STYLE=true
depends_on:
minio:
condition: service_healthy
minio:
image: minio/minio:latest
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 5s
retries: 5
volumes:
- minio_data:/data
volumes:
minio_data:
```
### 2. Start the services
```bash
docker compose up -d
```
### 3. Verify the server is running
```bash
# Health check
curl http://localhost:3929/health
# Expected: {"status":"ok"}
# Readiness check (verifies S3 connectivity)
curl http://localhost:3929/readyz
# Expected: {"status":"ready","s3":true}
```
## Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
| `SYNC_TOKEN` | Yes | - | Bearer token used to authenticate requests from Donut Browser clients |
| `PORT` | No | `3929` | Port the sync server listens on |
| `S3_ENDPOINT` | No | - | S3-compatible endpoint URL (e.g., `http://minio:9000` or `https://s3.amazonaws.com`) |
| `S3_REGION` | No | `us-east-1` | S3 region |
| `S3_ACCESS_KEY_ID` | Yes | - | S3 access key |
| `S3_SECRET_ACCESS_KEY` | Yes | - | S3 secret key |
| `S3_BUCKET` | No | `donut-sync` | S3 bucket name for storing sync data |
| `S3_FORCE_PATH_STYLE` | No | `false` | Set to `true` for MinIO and other S3-compatible services that use path-style URLs |
## Using External S3 Storage
Instead of running MinIO, you can use any S3-compatible storage service. Remove the `minio` service from `docker-compose.yml` and update the environment variables:
### AWS S3
```yaml
services:
donut-sync:
image: donutbrowser/donut-sync:latest
ports:
- "3929:3929"
environment:
- SYNC_TOKEN=your-secret-token-here
- S3_REGION=us-east-1
- S3_ACCESS_KEY_ID=your-aws-access-key
- S3_SECRET_ACCESS_KEY=your-aws-secret-key
- S3_BUCKET=your-bucket-name
```
### Cloudflare R2
```yaml
services:
donut-sync:
image: donutbrowser/donut-sync:latest
ports:
- "3929:3929"
environment:
- SYNC_TOKEN=your-secret-token-here
- S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
- S3_REGION=auto
- S3_ACCESS_KEY_ID=your-r2-access-key
- S3_SECRET_ACCESS_KEY=your-r2-secret-key
- S3_BUCKET=your-bucket-name
- S3_FORCE_PATH_STYLE=true
```
### Other S3-Compatible Services
Any service that implements the S3 API (e.g., Backblaze B2, DigitalOcean Spaces, Wasabi) can be used. Set `S3_ENDPOINT` to the service's endpoint URL and `S3_FORCE_PATH_STYLE=true` if required by the provider.
## Configuring the Donut Browser Client
1. Open Donut Browser
2. Click the sync icon in the header to open the Sync Configuration dialog
3. Enter the **Server URL** (e.g., `http://your-server:3929`)
4. Enter the **Sync Token** (the value you set for `SYNC_TOKEN`)
5. Click **Save**
Once configured, you can enable sync on individual profiles, proxies, and groups.
## Health Check Endpoints
| Endpoint | Description |
|---|---|
| `GET /health` | Basic health check. Returns `{"status":"ok"}` if the server is running. |
| `GET /readyz` | Readiness check. Verifies S3 connectivity. Returns `{"status":"ready","s3":true}` or HTTP 503 if S3 is unreachable. |
## Security Considerations
- **Use a strong `SYNC_TOKEN`**: Generate a random token (e.g., `openssl rand -hex 32`) and keep it secret.
- **HTTPS**: In production, place a reverse proxy (e.g., Nginx, Caddy, Traefik) in front of Donut Sync to terminate TLS. The sync token is sent as a Bearer token in the `Authorization` header and should not be transmitted over plain HTTP.
- **Network isolation**: If running on a VPS, consider restricting access to the sync port using firewall rules or binding only to localhost behind a reverse proxy.
- **S3 credentials**: Use dedicated IAM credentials with minimal permissions (read/write to the sync bucket only).
### Example: Caddy Reverse Proxy
```
sync.yourdomain.com {
reverse_proxy localhost:3929
}
```
### Example: Nginx Reverse Proxy
```nginx
server {
listen 443 ssl;
server_name sync.yourdomain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3929;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
View File
-10
View File
@@ -1,10 +0,0 @@
.env
.env.*
!.env.example
coverage
.nyc_output
.temp
.tmp
.git
*.log
test
-9
View File
@@ -1,9 +0,0 @@
SYNC_TOKEN=secret-sync-token
PORT=12342
S3_ENDPOINT=http://localhost:8987
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin
S3_BUCKET=donut-sync
S3_FORCE_PATH_STYLE=true
-56
View File
@@ -1,56 +0,0 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
-4
View File
@@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}
-21
View File
@@ -1,21 +0,0 @@
FROM node:22-alpine AS builder
WORKDIR /build
COPY donut-sync/package.json donut-sync/tsconfig.json donut-sync/tsconfig.build.json ./
COPY donut-sync/src/ src/
RUN npm install
RUN npm run build
RUN npm prune --omit=dev
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /build/package.json .
COPY --from=builder /build/dist/ dist/
COPY --from=builder /build/node_modules/ node_modules/
ENV NODE_ENV=production
EXPOSE 12342
USER node
CMD ["node", "dist/main"]
-96
View File
@@ -1,96 +0,0 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
pnpm install
```
## Compile and run the project
```bash
# development
pnpm run start
# watch mode
pnpm run start:dev
# production mode
pnpm run start:prod
```
## Run tests
```bash
# unit tests
pnpm run test
# e2e tests
pnpm run test:e2e
# test coverage
pnpm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
pnpm install -g @nestjs/mau
mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
-20
View File
@@ -1,20 +0,0 @@
services:
minio:
image: minio/minio:latest
ports:
- "8987:9000"
- "8988:9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 5s
retries: 5
volumes:
- minio_data:/data
volumes:
minio_data:
-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
-11
View File
@@ -1,11 +0,0 @@
[phases.setup]
nixPkgs = ["nodejs_22"]
[phases.install]
cmds = ["npm install --include=dev"]
[phases.build]
cmds = ["npm run build", "npm prune --omit=dev"]
[start]
cmd = "npm run start:prod"
-69
View File
@@ -1,69 +0,0 @@
{
"name": "donut-sync",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1045.0",
"@aws-sdk/s3-request-presigner": "^3.1045.0",
"@nestjs/common": "^11.1.19",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
"@nestjs/platform-express": "^11.1.19",
"jsonwebtoken": "^9.0.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.19",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.7.0",
"@types/supertest": "^7.2.0",
"jest": "^30.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-jest": "^29.4.9",
"ts-loader": "^9.5.7",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^6.0.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
-37
View File
@@ -1,37 +0,0 @@
import { Test, type TestingModule } from "@nestjs/testing";
import { AppController } from "./app.controller.js";
import { AppService } from "./app.service.js";
import { SyncService } from "./sync/sync.service.js";
describe("AppController", () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [
AppService,
{
provide: SyncService,
useValue: {
checkS3Connectivity: jest.fn().mockResolvedValue(true),
},
},
],
}).compile();
appController = app.get<AppController>(AppController);
});
describe("root", () => {
it("should return service name", () => {
expect(appController.getHello()).toBe("Donut Sync Service");
});
});
describe("health", () => {
it("should return ok status", () => {
expect(appController.getHealth()).toEqual({ status: "ok" });
});
});
});
-33
View File
@@ -1,33 +0,0 @@
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
import { AppService } from "./app.service.js";
import { SyncService } from "./sync/sync.service.js";
@Controller()
export class AppController {
constructor(
private readonly appService: AppService,
private readonly syncService: SyncService,
) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
@Get("health")
getHealth(): { status: string } {
return { status: "ok" };
}
@Get("readyz")
async getReadiness(): Promise<{ status: string; s3: boolean }> {
const s3Ready = await this.syncService.checkS3Connectivity();
if (!s3Ready) {
throw new HttpException(
{ status: "not ready", s3: false },
HttpStatus.SERVICE_UNAVAILABLE,
);
}
return { status: "ready", s3: true };
}
}
-17
View File
@@ -1,17 +0,0 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { AppController } from "./app.controller.js";
import { AppService } from "./app.service.js";
import { SyncModule } from "./sync/sync.module.js";
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
SyncModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
-8
View File
@@ -1,8 +0,0 @@
import { Injectable } from "@nestjs/common";
@Injectable()
export class AppService {
getHello(): string {
return "Donut Sync Service";
}
}
-82
View File
@@ -1,82 +0,0 @@
import {
type CanActivate,
type ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Request } from "express";
import * as jwt from "jsonwebtoken";
import type { UserContext } from "./user-context.interface.js";
@Injectable()
export class AuthGuard implements CanActivate {
private readonly logger = new Logger(AuthGuard.name);
private jwtPublicKey: string | null = null;
constructor(private configService: ConfigService) {
const publicKey = this.configService.get<string>("SYNC_JWT_PUBLIC_KEY");
if (publicKey) {
this.jwtPublicKey = publicKey.replace(/\\n/g, "\n");
this.logger.log("JWT public key configured — cloud auth enabled");
}
}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
throw new UnauthorizedException(
"Missing or invalid authorization header",
);
}
const token = authHeader.substring(7);
// Try SYNC_TOKEN first (self-hosted mode)
const expectedToken = this.configService.get<string>("SYNC_TOKEN");
if (expectedToken && token === expectedToken) {
(request as unknown as Record<string, unknown>).user = {
mode: "self-hosted",
prefix: "",
teamPrefix: null,
profileLimit: 0,
teamProfileLimit: 0,
} satisfies UserContext;
return true;
}
// Try JWT verification (cloud mode)
if (this.jwtPublicKey) {
try {
const decoded = jwt.verify(token, this.jwtPublicKey, {
algorithms: ["RS256"],
}) as jwt.JwtPayload;
(request as unknown as Record<string, unknown>).user = {
mode: "cloud",
prefix: decoded.prefix || `users/${decoded.sub}/`,
teamPrefix: decoded.teamPrefix || null,
profileLimit: decoded.profileLimit || 0,
teamProfileLimit: decoded.teamProfileLimit || 0,
} satisfies UserContext;
return true;
} catch (err) {
this.logger.warn(
`JWT verification failed: ${err instanceof Error ? err.message : err}`,
);
}
}
// If SYNC_TOKEN is configured but didn't match, or JWT failed
if (!expectedToken && !this.jwtPublicKey) {
throw new UnauthorizedException(
"No auth method configured on server (set SYNC_TOKEN or SYNC_JWT_PUBLIC_KEY)",
);
}
throw new UnauthorizedException("Invalid sync token or JWT");
}
}
@@ -1,7 +0,0 @@
export interface UserContext {
mode: "self-hosted" | "cloud";
prefix: string; // '' for self-hosted, 'users/{id}/' for cloud
teamPrefix: string | null; // 'teams/{id}/' or null
profileLimit: number; // 0 for unlimited (self-hosted)
teamProfileLimit: number; // 0 for unlimited or non-team users
}
-30
View File
@@ -1,30 +0,0 @@
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { AppModule } from "./app.module.js";
function validateEnv() {
if (!process.env.SYNC_TOKEN && !process.env.SYNC_JWT_PUBLIC_KEY) {
console.error("Either SYNC_TOKEN or SYNC_JWT_PUBLIC_KEY must be set");
process.exit(1);
}
}
async function bootstrap() {
validateEnv();
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// biome-ignore lint/correctness/useHookAtTopLevel: NestJS method, not a React hook
app.useBodyParser("json", { limit: "50mb" });
app.enableCors({
origin: "*",
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
});
const port = process.env.PORT ?? 3929;
await app.listen(port);
console.log(`Donut Sync service running on port ${port}`);
}
void bootstrap();
-122
View File
@@ -1,122 +0,0 @@
export class StatRequestDto {
key: string;
}
export class StatResponseDto {
exists: boolean;
lastModified?: string;
size?: number;
// User-defined S3 object metadata (lowercased keys, no `x-amz-meta-` prefix).
// Carries `updated-at` for sync conflict resolution via HEAD (no body GET).
metadata?: Record<string, string>;
}
export class PresignUploadRequestDto {
key: string;
contentType?: string;
expiresIn?: number;
// Object metadata to sign into the presigned PUT as `x-amz-meta-*`.
metadata?: Record<string, string>;
}
export class PresignUploadResponseDto {
url: string;
expiresAt: string;
// Metadata the server actually signed; the client must echo it as
// `x-amz-meta-*` headers on the PUT (older clients/servers omit it).
metadata?: Record<string, string>;
}
export class PresignDownloadRequestDto {
key: string;
expiresIn?: number;
}
export class PresignDownloadResponseDto {
url: string;
expiresAt: string;
}
export class DeleteRequestDto {
key: string;
tombstoneKey?: string;
deletedAt?: string;
}
export class DeleteResponseDto {
deleted: boolean;
tombstoneCreated: boolean;
}
export class ListRequestDto {
prefix: string;
maxKeys?: number;
continuationToken?: string;
}
export class ListObjectDto {
key: string;
lastModified: string;
size: number;
}
export class ListResponseDto {
objects: ListObjectDto[];
isTruncated: boolean;
nextContinuationToken?: string;
}
export class SubscribeEventDto {
type: "change" | "delete" | "ping";
key?: string;
lastModified?: string;
size?: number;
}
// Batch presign DTOs
export class PresignUploadBatchItemDto {
key: string;
contentType?: string;
}
export class PresignUploadBatchRequestDto {
items: PresignUploadBatchItemDto[];
expiresIn?: number;
}
export class PresignUploadBatchItemResponseDto {
key: string;
url: string;
expiresAt: string;
}
export class PresignUploadBatchResponseDto {
items: PresignUploadBatchItemResponseDto[];
}
export class PresignDownloadBatchRequestDto {
keys: string[];
expiresIn?: number;
}
export class PresignDownloadBatchItemResponseDto {
key: string;
url: string;
expiresAt: string;
}
export class PresignDownloadBatchResponseDto {
items: PresignDownloadBatchItemResponseDto[];
}
// Delete prefix DTOs
export class DeletePrefixRequestDto {
prefix: string;
tombstoneKey?: string;
deletedAt?: string;
}
export class DeletePrefixResponseDto {
deletedCount: number;
tombstoneCreated: boolean;
}
@@ -1,38 +0,0 @@
import {
Body,
Controller,
Headers,
HttpCode,
Post,
UnauthorizedException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { SyncService } from "./sync.service.js";
@Controller("v1/internal")
export class InternalController {
private readonly internalKey: string | undefined;
constructor(
private readonly syncService: SyncService,
private readonly configService: ConfigService,
) {
this.internalKey = this.configService.get<string>("INTERNAL_KEY");
}
@Post("cleanup-excess-profiles")
@HttpCode(200)
async cleanupExcessProfiles(
@Headers("x-internal-key") key: string,
@Body() body: { userId: string; maxProfiles: number },
) {
if (!this.internalKey || key !== this.internalKey) {
throw new UnauthorizedException("Invalid internal key");
}
return this.syncService.cleanupExcessProfiles(
body.userId,
body.maxProfiles,
);
}
}
-126
View File
@@ -1,126 +0,0 @@
import {
Body,
Controller,
Get,
HttpCode,
type MessageEvent,
Post,
Req,
Sse,
UseGuards,
} from "@nestjs/common";
import type { Request } from "express";
import { map, type Observable } from "rxjs";
import { AuthGuard } from "../auth/auth.guard.js";
import type { UserContext } from "../auth/user-context.interface.js";
import type {
DeletePrefixRequestDto,
DeletePrefixResponseDto,
DeleteRequestDto,
DeleteResponseDto,
ListRequestDto,
ListResponseDto,
PresignDownloadBatchRequestDto,
PresignDownloadBatchResponseDto,
PresignDownloadRequestDto,
PresignDownloadResponseDto,
PresignUploadBatchRequestDto,
PresignUploadBatchResponseDto,
PresignUploadRequestDto,
PresignUploadResponseDto,
StatRequestDto,
StatResponseDto,
} from "./dto/sync.dto.js";
import { SyncService } from "./sync.service.js";
@Controller("v1/objects")
@UseGuards(AuthGuard)
export class SyncController {
constructor(private readonly syncService: SyncService) {}
private getUserContext(req: Request): UserContext {
return (req as unknown as Record<string, unknown>).user as UserContext;
}
@Post("stat")
@HttpCode(200)
async stat(
@Body() dto: StatRequestDto,
@Req() req: Request,
): Promise<StatResponseDto> {
return this.syncService.stat(dto, this.getUserContext(req));
}
@Post("presign-upload")
@HttpCode(200)
async presignUpload(
@Body() dto: PresignUploadRequestDto,
@Req() req: Request,
): Promise<PresignUploadResponseDto> {
return this.syncService.presignUpload(dto, this.getUserContext(req));
}
@Post("presign-download")
@HttpCode(200)
async presignDownload(
@Body() dto: PresignDownloadRequestDto,
@Req() req: Request,
): Promise<PresignDownloadResponseDto> {
return this.syncService.presignDownload(dto, this.getUserContext(req));
}
@Post("delete")
@HttpCode(200)
async delete(
@Body() dto: DeleteRequestDto,
@Req() req: Request,
): Promise<DeleteResponseDto> {
return this.syncService.delete(dto, this.getUserContext(req));
}
@Post("list")
@HttpCode(200)
async list(
@Body() dto: ListRequestDto,
@Req() req: Request,
): Promise<ListResponseDto> {
return this.syncService.list(dto, this.getUserContext(req));
}
@Post("presign-upload-batch")
@HttpCode(200)
async presignUploadBatch(
@Body() dto: PresignUploadBatchRequestDto,
@Req() req: Request,
): Promise<PresignUploadBatchResponseDto> {
return this.syncService.presignUploadBatch(dto, this.getUserContext(req));
}
@Post("presign-download-batch")
@HttpCode(200)
async presignDownloadBatch(
@Body() dto: PresignDownloadBatchRequestDto,
@Req() req: Request,
): Promise<PresignDownloadBatchResponseDto> {
return this.syncService.presignDownloadBatch(dto, this.getUserContext(req));
}
@Post("delete-prefix")
@HttpCode(200)
async deletePrefix(
@Body() dto: DeletePrefixRequestDto,
@Req() req: Request,
): Promise<DeletePrefixResponseDto> {
return this.syncService.deletePrefix(dto, this.getUserContext(req));
}
@Get("subscribe")
@Sse()
subscribe(@Req() req: Request): Observable<MessageEvent> {
return this.syncService.subscribe(this.getUserContext(req), 5000).pipe(
map((event) => ({
data: event,
})),
);
}
}
-12
View File
@@ -1,12 +0,0 @@
import { Module } from "@nestjs/common";
import { AuthGuard } from "../auth/auth.guard.js";
import { InternalController } from "./internal.controller.js";
import { SyncController } from "./sync.controller.js";
import { SyncService } from "./sync.service.js";
@Module({
controllers: [SyncController, InternalController],
providers: [SyncService, AuthGuard],
exports: [SyncService],
})
export class SyncModule {}
File diff suppressed because it is too large Load Diff
-47
View File
@@ -1,47 +0,0 @@
import { INestApplication } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import request from "supertest";
import { App } from "supertest/types";
import { AppController } from "./../src/app.controller.js";
import { AppService } from "./../src/app.service.js";
import { SyncService } from "./../src/sync/sync.service.js";
describe("AppController (e2e)", () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [
AppService,
{
provide: SyncService,
useValue: {
checkS3Connectivity: async () => true,
},
},
],
}).compile();
app = moduleFixture.createNestApplication();
await app.listen(0);
});
afterEach(async () => {
await app.close();
});
it("/ (GET)", () => {
return request(app.getHttpServer())
.get("/")
.expect(200)
.expect("Donut Sync Service");
});
it("/health (GET)", () => {
return request(app.getHttpServer())
.get("/health")
.expect(200)
.expect({ status: "ok" });
});
});
-18
View File
@@ -1,18 +0,0 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"maxWorkers": 1,
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": [
"ts-jest",
{
"tsconfig": "<rootDir>/tsconfig.json"
}
]
},
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1"
}
}
-258
View File
@@ -1,258 +0,0 @@
import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import { INestApplication } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import request from "supertest";
import { App } from "supertest/types";
import { AppController } from "./../src/app.controller.js";
import { AppService } from "./../src/app.service.js";
import { SyncModule } from "./../src/sync/sync.module.js";
import {
configureTestEnv,
TEST_SYNC_TOKEN,
waitForTestS3,
} from "./test-env.js";
interface PresignResponse {
url: string;
expiresAt: string;
}
interface ListResponse {
objects: Array<{ key: string; lastModified: string; size: number }>;
isTruncated: boolean;
nextContinuationToken?: string;
}
interface DeleteResponse {
deleted: boolean;
tombstoneCreated: boolean;
}
interface StatResponse {
exists: boolean;
size?: number;
lastModified?: string;
}
describe("SyncController (e2e)", () => {
let app: INestApplication<App>;
beforeAll(async () => {
configureTestEnv();
await waitForTestS3();
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
SyncModule,
],
controllers: [AppController],
providers: [AppService],
}).compile();
app = moduleFixture.createNestApplication();
await app.listen(0);
});
afterAll(async () => {
await app.close();
});
describe("Authentication", () => {
it("should reject requests without authorization header", () => {
return request(app.getHttpServer())
.post("/v1/objects/stat")
.send({ key: "test-key" })
.expect(401);
});
it("should reject requests with invalid token", () => {
return request(app.getHttpServer())
.post("/v1/objects/stat")
.set("Authorization", "Bearer invalid-token")
.send({ key: "test-key" })
.expect(401);
});
it("should accept requests with valid token", () => {
return request(app.getHttpServer())
.post("/v1/objects/stat")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: "nonexistent-key" })
.expect(200)
.expect({ exists: false });
});
});
describe("POST /v1/objects/stat", () => {
it("should return exists: false for non-existent key", () => {
return request(app.getHttpServer())
.post("/v1/objects/stat")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: "does-not-exist" })
.expect(200)
.expect({ exists: false });
});
});
describe("POST /v1/objects/presign-upload", () => {
it("should return a presigned upload URL", async () => {
const response = await request(app.getHttpServer())
.post("/v1/objects/presign-upload")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: "test/upload-key.txt", contentType: "text/plain" })
.expect(200);
const body = response.body as PresignResponse;
expect(body.url).toBeDefined();
expect(body.url).toContain("test/upload-key.txt");
expect(body.expiresAt).toBeDefined();
});
});
describe("POST /v1/objects/presign-download", () => {
it("should return a presigned download URL", async () => {
const response = await request(app.getHttpServer())
.post("/v1/objects/presign-download")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: "test/download-key.txt" })
.expect(200);
const body = response.body as PresignResponse;
expect(body.url).toBeDefined();
expect(body.url).toContain("test/download-key.txt");
expect(body.expiresAt).toBeDefined();
});
});
describe("POST /v1/objects/list", () => {
it("should list objects with prefix", async () => {
const response = await request(app.getHttpServer())
.post("/v1/objects/list")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ prefix: "profiles/" })
.expect(200);
const body = response.body as ListResponse;
expect(body.objects).toBeDefined();
expect(Array.isArray(body.objects)).toBe(true);
expect(body.isTruncated).toBeDefined();
});
});
describe("POST /v1/objects/delete", () => {
it("should delete object and create tombstone", async () => {
const response = await request(app.getHttpServer())
.post("/v1/objects/delete")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({
key: "test/to-delete.txt",
tombstoneKey: "tombstones/test/to-delete.json",
deletedAt: new Date().toISOString(),
})
.expect(200);
const body = response.body as DeleteResponse;
expect(body.deleted).toBeDefined();
expect(body.tombstoneCreated).toBe(true);
});
});
describe("Full upload/download cycle", () => {
const testKey = `test/e2e-cycle-${Date.now()}.txt`;
const testContent = "Hello from e2e test!";
it("should complete full upload/download cycle with presigned URLs", async () => {
const uploadResponse = await request(app.getHttpServer())
.post("/v1/objects/presign-upload")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: testKey, contentType: "text/plain" })
.expect(200);
const uploadBody = uploadResponse.body as PresignResponse;
expect(uploadBody.url).toBeDefined();
const uploadResult = await fetch(uploadBody.url, {
method: "PUT",
body: testContent,
headers: { "Content-Type": "text/plain" },
});
expect(uploadResult.ok).toBe(true);
const statResponse = await request(app.getHttpServer())
.post("/v1/objects/stat")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: testKey })
.expect(200);
const statBody = statResponse.body as StatResponse;
expect(statBody.exists).toBe(true);
expect(statBody.size).toBeGreaterThan(0);
const downloadResponse = await request(app.getHttpServer())
.post("/v1/objects/presign-download")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: testKey })
.expect(200);
const downloadBody = downloadResponse.body as PresignResponse;
const downloadResult = await fetch(downloadBody.url);
expect(downloadResult.ok).toBe(true);
const downloadedContent = await downloadResult.text();
expect(downloadedContent).toBe(testContent);
await request(app.getHttpServer())
.post("/v1/objects/delete")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: testKey })
.expect(200);
const finalStatResponse = await request(app.getHttpServer())
.post("/v1/objects/stat")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: testKey })
.expect(200);
const finalStatBody = finalStatResponse.body as StatResponse;
expect(finalStatBody.exists).toBe(false);
});
});
describe("GET /v1/objects/subscribe (SSE)", () => {
it("should reject SSE without authorization", () => {
return request(app.getHttpServer())
.get("/v1/objects/subscribe")
.expect(401);
});
it("should return SSE stream with valid token", async () => {
const address = (
app.getHttpServer() as Server
).address() as AddressInfo | null;
if (!address || typeof address === "string") {
throw new Error("Expected app to be listening on a TCP port");
}
const response = await fetch(
`http://127.0.0.1:${address.port}/v1/objects/subscribe`,
{
headers: {
Accept: "text/event-stream",
Authorization: `Bearer ${TEST_SYNC_TOKEN}`,
},
},
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain(
"text/event-stream",
);
await response.body?.cancel();
});
});
});
-37
View File
@@ -1,37 +0,0 @@
import { ListBucketsCommand, S3Client } from "@aws-sdk/client-s3";
export const TEST_SYNC_TOKEN = "test-sync-token";
export const TEST_S3_ENDPOINT = "http://127.0.0.1:8987";
export function configureTestEnv() {
process.env.SYNC_TOKEN ||= TEST_SYNC_TOKEN;
process.env.S3_ENDPOINT ||= TEST_S3_ENDPOINT;
process.env.S3_ACCESS_KEY_ID ||= "minioadmin";
process.env.S3_SECRET_ACCESS_KEY ||= "minioadmin";
process.env.S3_BUCKET ||= "donut-sync-test";
process.env.S3_FORCE_PATH_STYLE ||= "true";
}
export async function waitForTestS3(timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs;
const s3Client = new S3Client({
endpoint: TEST_S3_ENDPOINT,
region: "us-east-1",
credentials: {
accessKeyId: "minioadmin",
secretAccessKey: "minioadmin",
},
forcePathStyle: true,
});
while (Date.now() < deadline) {
try {
await s3Client.send(new ListBucketsCommand({}));
return;
} catch {}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for S3 at ${TEST_S3_ENDPOINT}`);
}
-6
View File
@@ -1,6 +0,0 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": ".."
}
}
-7
View File
@@ -1,7 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src"
},
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
-26
View File
@@ -1,26 +0,0 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"strictPropertyInitialization": false,
"types": ["jest", "node"],
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}
Generated
-61
View File
@@ -1,61 +0,0 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
-343
View File
@@ -1,343 +0,0 @@
{
description = "Donut Browser development environment and quick-start commands";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
};
lib = pkgs.lib;
nodejs =
if pkgs ? nodejs_23 then
pkgs.nodejs_23
else
pkgs.nodejs_22;
rustPackages = with pkgs; [
cargo
clippy
rust-analyzer
rustc
rustfmt
];
commonLibs = with pkgs; [
webkitgtk_4_1
libsoup_3
glib
gtk3
libayatana-appindicator
cairo
gdk-pixbuf
pango
atk
at-spi2-atk
at-spi2-core
dbus
nss
nspr
libdrm
libgbm
libxkbcommon
libx11
libxcomposite
libxdamage
libxext
libxfixes
libxrandr
libxcb
libxshmfence
libxtst
libxi
xdotool
libxrender
libxinerama
libxcursor
libxscrnsaver
fontconfig
freetype
fribidi
harfbuzz
expat
libglvnd
libgpg-error
e2fsprogs
gmp
zlib
stdenv.cc.cc.lib
];
runtimeLibPath = lib.makeLibraryPath commonLibs;
nixLd = pkgs.stdenv.cc.bintools.dynamicLinker;
pkgConfigLibs = [
pkgs.at-spi2-atk
pkgs.at-spi2-core
pkgs.cairo
pkgs.dbus
pkgs.gdk-pixbuf
pkgs.glib
pkgs.gtk3
pkgs.libayatana-appindicator
pkgs.libsoup_3
pkgs.libxkbcommon
pkgs.openssl
pkgs.pango
pkgs.harfbuzz
pkgs.webkitgtk_4_1
];
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.24.4";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_amd64.AppImage";
hash = "sha256-YNXPed96GmuMhJVERxa2gYtiaQoMfdB0az5O5J0b/No=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_aarch64.AppImage";
hash = "sha256-kdEzMO53bCUH7E8GPDewnIDLRIO5pWlO8B4TdpLAQIg=";
}
else
null;
releaseUnpacked =
if releaseAppImage != null then
pkgs.stdenvNoCC.mkDerivation {
pname = "donut-release-unpacked";
version = releaseVersion;
src = releaseAppImage;
dontUnpack = true;
nativeBuildInputs = [ pkgs.xz ];
installPhase = ''
runHook preInstall
cp "$src" ./donut.AppImage
chmod +x ./donut.AppImage
./donut.AppImage --appimage-extract >/dev/null
mkdir -p "$out"
cp -a ./squashfs-root "$out/"
runHook postInstall
'';
}
else
null;
releaseWrapped =
if releaseAppImage != null then
pkgs.appimageTools.wrapType2 {
pname = "donut";
version = releaseVersion;
src = releaseAppImage;
extraPkgs = _: commonLibs;
extraInstallCommands = ''
for bin in "$out"/bin/*; do
if [ -f "$bin" ]; then
mv "$bin" "$out/bin/donut-release"
break
fi
done
'';
}
else
null;
releaseLauncher =
if releaseUnpacked != null then
pkgs.writeShellApplication {
name = "donut-release-start";
runtimeInputs = with pkgs; [
coreutils
xdg-utils
];
text = ''
set -euo pipefail
if [ -x "${releaseWrapped}/bin/donut-release" ]; then
if "${releaseWrapped}/bin/donut-release" "$@"; then
exit 0
fi
echo "Wrapped AppImage failed, retrying with direct AppRun..." >&2
fi
export LD_LIBRARY_PATH="${releaseUnpacked}/squashfs-root/usr/lib:${releaseUnpacked}/squashfs-root/usr/lib64:${runtimeLibPath}:''${LD_LIBRARY_PATH:-}"
export NIX_LD_LIBRARY_PATH="$LD_LIBRARY_PATH"
export LIBRARY_PATH="$LD_LIBRARY_PATH"
export XDG_DATA_DIRS="${releaseUnpacked}/squashfs-root/usr/share:''${XDG_DATA_DIRS:-}"
exec "${releaseUnpacked}/squashfs-root/AppRun" "$@"
'';
}
else
pkgs.writeShellApplication {
name = "donut-release-start";
text = ''
echo "Release launcher is supported only on Linux (x86_64/aarch64)."
exit 1
'';
};
mkApp = name: text:
let
app = pkgs.writeShellApplication {
inherit name;
runtimeInputs = with pkgs; [
bash
coreutils
findutils
git
gnugrep
gnused
curl
gcc
pkg-config
openssl
cargo
clippy
rustc
rustfmt
nodejs
pnpm
cargo-tauri
];
text = ''
export NODE_ENV=development
export NIX_LD="${nixLd}"
export NIX_LD_LIBRARY_PATH="${runtimeLibPath}:''${NIX_LD_LIBRARY_PATH:-}"
export LD_LIBRARY_PATH="${runtimeLibPath}:''${LD_LIBRARY_PATH:-}"
export LIBRARY_PATH="${runtimeLibPath}:''${LIBRARY_PATH:-}"
export PKG_CONFIG_PATH="${pkgConfigPath}:''${PKG_CONFIG_PATH:-}"
export RUST_SRC_PATH="${pkgs.rustPlatform.rustLibSrc}"
${text}
'';
};
in
{
type = "app";
program = "${app}/bin/${name}";
};
in
{
devShells.default = pkgs.mkShell {
packages = with pkgs; [
nodejs
pnpm
cargo-tauri
pkg-config
openssl
git
bashInteractive
gnumake
clang
llvmPackages.bintools
python3
curl
wget
unzip
zip
xz
biome
docker
] ++ rustPackages ++ commonLibs;
shellHook = ''
export NODE_ENV=development
export NIX_LD="${nixLd}"
export NIX_LD_LIBRARY_PATH="${runtimeLibPath}:''${NIX_LD_LIBRARY_PATH:-}"
export LD_LIBRARY_PATH="${runtimeLibPath}:''${LD_LIBRARY_PATH:-}"
export LIBRARY_PATH="${runtimeLibPath}:''${LIBRARY_PATH:-}"
export PKG_CONFIG_PATH="${pkgConfigPath}:''${PKG_CONFIG_PATH:-}"
export RUST_SRC_PATH="${pkgs.rustPlatform.rustLibSrc}"
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share:${pkgs.gtk3}/share:''${XDG_DATA_DIRS:-}"
echo "Donut Browser dev shell ready."
echo "Quick start:"
echo " nix run .#setup"
echo " nix run .#tauri-dev"
echo " nix run .#full-dev"
echo " nix run .#build"
echo " nix run .#test"
echo " nix run .#release-start"
'';
};
apps.info = mkApp "donut-info" ''
set -euo pipefail
echo "Node: $(node --version)"
echo "pnpm: $(pnpm --version)"
echo "Rust: $(rustc --version)"
echo "Cargo: $(cargo --version)"
echo "Tauri CLI: $(cargo-tauri --version)"
'';
apps.deps = mkApp "donut-deps" ''
set -euo pipefail
pnpm install
'';
apps.dev = mkApp "donut-dev" ''
set -euo pipefail
pnpm dev
'';
apps."tauri-dev" = mkApp "donut-tauri-dev" ''
set -euo pipefail
pnpm tauri dev
'';
apps."full-dev" = mkApp "donut-full-dev" ''
set -euo pipefail
chmod +x ./scripts/dev.sh
./scripts/dev.sh
'';
apps.build = mkApp "donut-build" ''
set -euo pipefail
pnpm build
(cd src-tauri && cargo build)
'';
apps.start = mkApp "donut-start" ''
set -euo pipefail
pnpm start
'';
apps.test = mkApp "donut-test" ''
set -euo pipefail
pnpm format && pnpm lint && pnpm test
'';
apps.setup = mkApp "donut-setup" ''
set -euo pipefail
if [ ! -f "package.json" ]; then
echo "package.json not found. Run this from the donutbrowser repo root."
exit 1
fi
pnpm install
pnpm copy-proxy-binary
echo "Setup complete."
echo "Run the app with:"
echo " nix run .#tauri-dev"
echo "Or run full local stack (sync + minio + tauri):"
echo " nix run .#full-dev"
'';
apps."release-start" = {
type = "app";
program = "${releaseLauncher}/bin/donut-release-start";
};
apps.default = self.apps.${system}.setup;
});
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-3
View File
@@ -7,9 +7,6 @@ const nextConfig: NextConfig = {
unoptimized: true,
},
distDir: "dist",
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
};
export default nextConfig;
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# Determine file extension based on platform
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" || "$OSTYPE" == "cygwin" ]]; then
EXT=".exe"
else
EXT=""
fi
# If architecture provided in the command line, use it to rename the binary in TARGET_TRIPLE
if [ -n "$1" ]; then
TARGET_TRIPLE="$1"
else
RUST_INFO=$(rustc -vV)
TARGET_TRIPLE=$(echo "$RUST_INFO" | grep -o 'host: [^ ]*' | cut -d' ' -f2)
fi
# Check if target triple was found
if [ -z "$TARGET_TRIPLE" ]; then
echo "Failed to determine platform target triple" >&2
exit 1
fi
# Copy the file with target triple suffix
cp "dist/nodecar${EXT}" "../src-tauri/binaries/nodecar-${TARGET_TRIPLE}${EXT}"
# Also copy a generic version for Tauri to find
cp "dist/nodecar${EXT}" "../src-tauri/binaries/nodecar${EXT}"
+47
View File
@@ -0,0 +1,47 @@
{
"name": "nodecar",
"version": "1.0.0",
"description": "",
"main": "dist/index.js",
"type": "commonjs",
"scripts": {
"watch": "nodemon --exec ts-node --esm ./src/index.ts --watch src",
"dev": "node --loader ts-node/esm ./src/index.ts",
"start": "tsc && node ./dist/index.js",
"test": "tsc && node ./dist/test-proxy.js",
"rename-binary": "sh ./copy-binary.sh",
"build": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar --option experimental-require-module --public && pnpm rename-binary",
"build:mac-aarch64": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar --option experimental-require-module --public&& pnpm rename-binary",
"build:mac-x86_64": "tsc && pkg ./dist/index.js --targets latest-macos-x64 --output dist/nodecar --option experimental-require-module --public && pnpm rename-binary",
"build:linux-x64": "tsc && pkg ./dist/index.js --targets latest-linux-x64 --output dist/nodecar --option experimental-require-module --public && pnpm rename-binary",
"build:linux-arm64": "tsc && pkg ./dist/index.js --targets latest-linux-arm64 --output dist/nodecar --option experimental-require-module --public && pnpm rename-binary",
"build:win-x64": "tsc && pkg ./dist/index.js --targets latest-win-x64 --output dist/nodecar --option experimental-require-module --public && pnpm rename-binary",
"build:win-arm64": "tsc && pkg ./dist/index.js --targets latest-win-arm64 --output dist/nodecar --option experimental-require-module --public && pnpm rename-binary"
},
"keywords": [],
"author": "",
"license": "AGPL-3.0",
"dependencies": {
"@types/node": "^24.0.10",
"@yao-pkg/pkg": "^6.5.1",
"commander": "^14.0.0",
"dotenv": "^17.0.1",
"get-port": "^7.1.0",
"nodemon": "^3.1.10",
"proxy-chain": "^2.5.9",
"tmp": "^0.2.3",
"ts-node": "^10.9.2",
"typescript": "^5.8.3"
},
"devDependencies": {
"@types/tmp": "^0.2.6"
},
"pkg": {
"assets": [
"node_modules/camoufox-js/**/*"
],
"scripts": [
"node_modules/camoufox-js/**/*.js"
]
}
}
+403
View File
@@ -0,0 +1,403 @@
import { spawn } from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
export interface CamoufoxConfig {
id: string;
pid?: number;
executablePath: string;
profilePath: string;
url?: string;
options: CamoufoxLaunchOptions;
}
export interface CamoufoxLaunchOptions {
// Operating system to use for fingerprint generation
os?: "windows" | "macos" | "linux" | ("windows" | "macos" | "linux")[];
// Blocking options
block_images?: boolean;
block_webrtc?: boolean;
block_webgl?: boolean;
// Security options
disable_coop?: boolean;
// Geolocation options
geoip?: string | boolean;
// UI behavior
humanize?: boolean | number;
// Localization
locale?: string | string[];
// Extensions and fonts
addons?: string[];
fonts?: string[];
custom_fonts_only?: boolean;
exclude_addons?: "UBO"[];
// Screen and window
screen?: {
minWidth?: number;
maxWidth?: number;
minHeight?: number;
maxHeight?: number;
};
window?: [number, number];
// Fingerprint
fingerprint?: any;
// Version and mode
ff_version?: number;
headless?: boolean;
main_world_eval?: boolean;
// Custom executable path
executable_path?: string;
// Firefox preferences
firefox_user_prefs?: Record<string, any>;
// Proxy settings
proxy?:
| string
| {
server: string;
username?: string;
password?: string;
bypass?: string;
};
// Cache and performance
enable_cache?: boolean;
// Additional options
args?: string[];
env?: Record<string, string | number | boolean>;
debug?: boolean;
virtual_display?: string;
webgl_config?: [string, string];
// Custom options - these may not be directly supported by camoufox-js
timezone?: string;
country?: string;
geolocation?: {
latitude: number;
longitude: number;
accuracy?: number;
};
// Add i_know_what_im_doing to match camoufox-js
i_know_what_im_doing?: boolean;
// Allow any additional properties that camoufox-js might accept
[key: string]: any;
}
// Store for active Camoufox processes
const activeCamoufoxProcesses = new Map<string, CamoufoxConfig>();
/**
* Generate a unique ID for the Camoufox instance
*/
function generateCamoufoxId(): string {
return `camoufox_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Save Camoufox configuration to storage
*/
function saveCamoufoxConfig(config: CamoufoxConfig): void {
try {
const configDir = path.join(os.tmpdir(), "nodecar_camoufox");
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
const configFile = path.join(configDir, `${config.id}.json`);
fs.writeFileSync(configFile, JSON.stringify(config, null, 2));
activeCamoufoxProcesses.set(config.id, config);
} catch (error) {
console.error(`Failed to save Camoufox config: ${error}`);
}
}
/**
* Load Camoufox configuration from storage
*/
function loadCamoufoxConfig(id: string): CamoufoxConfig | null {
try {
const configFile = path.join(os.tmpdir(), "nodecar_camoufox", `${id}.json`);
if (fs.existsSync(configFile)) {
const config = JSON.parse(fs.readFileSync(configFile, "utf8"));
activeCamoufoxProcesses.set(id, config);
return config;
}
} catch (error) {
console.error(`Failed to load Camoufox config: ${error}`);
}
return null;
}
/**
* Delete Camoufox configuration from storage
*/
function deleteCamoufoxConfig(id: string): boolean {
try {
const configFile = path.join(os.tmpdir(), "nodecar_camoufox", `${id}.json`);
if (fs.existsSync(configFile)) {
fs.unlinkSync(configFile);
}
activeCamoufoxProcesses.delete(id);
return true;
} catch (error) {
console.error(`Failed to delete Camoufox config: ${error}`);
return false;
}
}
/**
* Load all Camoufox configurations on startup
*/
function loadAllCamoufoxConfigs(): void {
try {
const configDir = path.join(os.tmpdir(), "nodecar_camoufox");
if (fs.existsSync(configDir)) {
const files = fs.readdirSync(configDir);
for (const file of files) {
if (file.endsWith(".json")) {
const id = path.basename(file, ".json");
loadCamoufoxConfig(id);
}
}
}
} catch (error) {
console.error(`Failed to load Camoufox configs: ${error}`);
}
}
/**
* Check if a process is still running
*/
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return false;
}
}
/**
* Launch Camoufox browser with specified options
*/
export async function launchCamoufox(
executablePath: string,
profilePath: string,
options: CamoufoxLaunchOptions = {},
url?: string,
): Promise<CamoufoxConfig> {
const id = generateCamoufoxId();
// Ensure profile directory exists
if (!fs.existsSync(profilePath)) {
fs.mkdirSync(profilePath, { recursive: true });
}
try {
// Use camoufox-js launchOptions to generate proper configuration
// const { launchOptions } = require("camoufox-js");
// const launchConfig = await launchOptions({
// ...options,
// executable_path: executablePath,
// // Enable debug if requested
// debug: options.debug || false,
// // Set i_know_what_im_doing to true to bypass warnings since we're controlling this
// i_know_what_im_doing: true,
// });
//
const launchConfig: any = {};
if (options.debug) {
console.log(
"Generated launch config:",
JSON.stringify(launchConfig, null, 2),
);
}
// Extract the command line args and environment from the launch config
const args = [
"-profile",
profilePath,
"-no-remote",
...(launchConfig.args || []),
];
// Add URL if provided
if (url) {
args.push(url);
}
// Use the environment variables and other config from camoufox-js
const env: NodeJS.ProcessEnv = {
...process.env,
...(launchConfig.env || {}),
};
if (options.debug) {
console.log("Launch args:", args);
console.log(
"Environment variables set:",
Object.keys(env).filter(
(key) => key.startsWith("CAMOU_") || key.startsWith("DISPLAY"),
),
);
}
// Use the executable path from the launch config if available
const finalExecutablePath = launchConfig.executablePath || executablePath;
// Write Firefox user preferences to user.js if provided
if (
launchConfig.firefoxUserPrefs &&
Object.keys(launchConfig.firefoxUserPrefs).length > 0
) {
const userJsPath = path.join(profilePath, "user.js");
const preferences: string[] = [];
for (const [key, value] of Object.entries(
launchConfig.firefoxUserPrefs,
)) {
if (typeof value === "string") {
preferences.push(`user_pref("${key}", "${value}");`);
} else if (typeof value === "boolean") {
preferences.push(`user_pref("${key}", ${value});`);
} else if (typeof value === "number") {
preferences.push(`user_pref("${key}", ${value});`);
}
}
if (preferences.length > 0) {
fs.writeFileSync(userJsPath, preferences.join("\n"));
}
}
// Launch the process
const child = spawn(finalExecutablePath, args, {
env: env as NodeJS.ProcessEnv,
detached: true,
stdio: options.debug ? "inherit" : "ignore",
});
if (!child.pid) {
throw new Error("Failed to launch Camoufox process");
}
const config: CamoufoxConfig = {
id,
pid: child.pid,
executablePath: finalExecutablePath,
profilePath,
url,
options,
};
// Save configuration
saveCamoufoxConfig(config);
// Handle process exit
child.on("exit", (code, signal) => {
if (options.debug) {
console.log(
`Camoufox process ${child.pid} exited with code ${code}, signal ${signal}`,
);
}
deleteCamoufoxConfig(id);
});
child.on("error", (error) => {
console.error(`Camoufox process error: ${error}`);
deleteCamoufoxConfig(id);
});
// Detach the child process so it can continue running independently
child.unref();
return config;
} catch (error) {
console.error(`Failed to launch Camoufox: ${error}`);
throw error;
}
}
/**
* Stop a Camoufox process by ID
*/
export async function stopCamoufox(id: string): Promise<boolean> {
const config = activeCamoufoxProcesses.get(id) || loadCamoufoxConfig(id);
if (!config || !config.pid) {
return false;
}
try {
if (isProcessRunning(config.pid)) {
process.kill(config.pid, "SIGTERM");
// Wait a moment for graceful shutdown
await new Promise((resolve) => setTimeout(resolve, 2000));
// Force kill if still running
if (isProcessRunning(config.pid)) {
process.kill(config.pid, "SIGKILL");
}
}
deleteCamoufoxConfig(id);
return true;
} catch (error) {
console.error(`Failed to stop Camoufox process: ${error}`);
return false;
}
}
/**
* List all Camoufox processes
*/
export function listCamoufoxProcesses(): any[] {
loadAllCamoufoxConfigs();
// Filter out dead processes
const activeConfigs: any[] = [];
for (const [id, config] of activeCamoufoxProcesses) {
if (config.pid && isProcessRunning(config.pid)) {
// Ensure we have the required fields, fall back to empty strings if missing
const executablePath = config.executablePath || "";
const profilePath = config.profilePath || "";
// Return in snake_case format for Rust compatibility
// Always include executable_path and profile_path, even if empty
activeConfigs.push({
id: config.id,
pid: config.pid,
executable_path: executablePath,
profile_path: profilePath,
url: config.url || null,
options: config.options || {},
});
} else {
// Clean up dead processes
deleteCamoufoxConfig(id);
}
}
return activeConfigs;
}
// Load existing configurations on module initialization
loadAllCamoufoxConfigs();
+461
View File
@@ -0,0 +1,461 @@
import { program } from "commander";
import {
launchCamoufox,
listCamoufoxProcesses,
stopCamoufox,
} from "./camoufox-launcher";
import {
startProxyProcess,
stopAllProxyProcesses,
stopProxyProcess,
} from "./proxy-runner";
import { listProxyConfigs } from "./proxy-storage";
import { runProxyWorker } from "./proxy-worker";
// Command for proxy management
program
.command("proxy")
.argument("<action>", "start, stop, or list proxies")
.option("--host <host>", "upstream proxy host")
.option("--proxy-port <port>", "upstream proxy port", Number.parseInt)
.option("--type <type>", "proxy type (http, https, socks4, socks5)")
.option("--username <username>", "proxy username")
.option("--password <password>", "proxy password")
.option(
"-p, --port <number>",
"local port to use (random if not specified)",
Number.parseInt,
)
.option("--ignore-certificate", "ignore certificate errors for HTTPS proxies")
.option("--id <id>", "proxy ID for stop command")
.option(
"-u, --upstream <url>",
"upstream proxy URL (protocol://[username:password@]host:port)",
)
.description("manage proxy servers")
.action(
async (
action: string,
options: {
host?: string;
proxyPort?: number;
type?: string;
username?: string;
password?: string;
port?: number;
ignoreCertificate?: boolean;
id?: string;
upstream?: string;
},
) => {
if (action === "start") {
let upstreamUrl: string;
// Build upstream URL from individual components if provided
if (options.host && options.proxyPort && options.type) {
const protocol =
options.type === "socks4" || options.type === "socks5"
? options.type
: "http";
const auth =
options.username && options.password
? `${encodeURIComponent(options.username)}:${encodeURIComponent(
options.password,
)}@`
: "";
upstreamUrl = `${protocol}://${auth}${options.host}:${options.proxyPort}`;
} else if (options.upstream) {
upstreamUrl = options.upstream;
} else {
console.error(
"Error: Either --upstream URL or --host, --proxy-port, and --type are required",
);
console.log(
"Example: proxy start --host example.com --proxy-port 9000 --type http --username user --password pass",
);
process.exit(1);
return;
}
try {
const config = await startProxyProcess(upstreamUrl, {
port: options.port,
ignoreProxyCertificate: options.ignoreCertificate,
});
// Output the configuration as JSON for the Rust side to parse
console.log(
JSON.stringify({
id: config.id,
localPort: config.localPort,
localUrl: config.localUrl,
upstreamUrl: config.upstreamUrl,
}),
);
// Exit successfully to allow the process to detach
process.exit(0);
} catch (error: unknown) {
console.error(
`Failed to start proxy: ${
error instanceof Error ? error.message : JSON.stringify(error)
}`,
);
process.exit(1);
}
} else if (action === "stop") {
if (options.id) {
const stopped = await stopProxyProcess(options.id);
console.log(JSON.stringify({ success: stopped }));
} else if (options.upstream) {
// Find proxies with this upstream URL
const configs = listProxyConfigs().filter(
(config) => config.upstreamUrl === options.upstream,
);
if (configs.length === 0) {
console.error(`No proxies found for ${options.upstream}`);
process.exit(1);
return;
}
for (const config of configs) {
const stopped = await stopProxyProcess(config.id);
console.log(JSON.stringify({ success: stopped }));
}
} else {
await stopAllProxyProcesses();
console.log(JSON.stringify({ success: true }));
}
process.exit(0);
} else if (action === "list") {
const configs = listProxyConfigs();
console.log(JSON.stringify(configs));
process.exit(0);
} else {
console.error("Invalid action. Use 'start', 'stop', or 'list'");
process.exit(1);
}
},
);
// Command for proxy worker (internal use)
program
.command("proxy-worker")
.argument("<action>", "start a proxy worker")
.requiredOption("--id <id>", "proxy configuration ID")
.description("run a proxy worker process")
.action(async (action: string, options: { id: string }) => {
if (action === "start") {
await runProxyWorker(options.id);
} else {
console.error("Invalid action for proxy-worker. Use 'start'");
process.exit(1);
}
});
// Command for Camoufox browser orchestrator
program
.command("camoufox")
.argument("<action>", "launch, stop, list, or open-url for Camoufox browser")
.requiredOption("--executable-path <path>", "path to Camoufox executable")
.requiredOption("--profile-path <path>", "path to browser profile directory")
.option("--url <url>", "URL to open")
.option("--id <id>", "Camoufox instance ID (for stop/open-url actions)")
// Operating system fingerprinting
.option(
"--os <os>",
"OS to emulate (windows, macos, linux, or comma-separated list)",
)
// Blocking options
.option("--block-images", "block all images")
.option("--block-webrtc", "block WebRTC entirely")
.option("--block-webgl", "block WebGL")
// Security options
.option("--disable-coop", "disable Cross-Origin-Opener-Policy")
// Geolocation and IP
.option(
"--geoip <ip>",
"IP address for geolocation spoofing (or 'auto' for automatic)",
)
.option("--country <country>", "country code for geolocation")
.option("--timezone <timezone>", "timezone to spoof")
.option("--latitude <lat>", "latitude for geolocation", parseFloat)
.option("--longitude <lng>", "longitude for geolocation", parseFloat)
// UI and behavior
.option(
"--humanize [duration]",
"humanize cursor movement (optional max duration in seconds)",
(val) => (val ? parseFloat(val) : true),
)
.option("--headless", "run in headless mode")
// Localization
.option("--locale <locale>", "locale(s) to use (comma-separated)")
// Extensions and fonts
.option("--addons <addons>", "Firefox addons to load (comma-separated paths)")
.option("--fonts <fonts>", "additional fonts to load (comma-separated)")
.option("--custom-fonts-only", "use only custom fonts, exclude OS fonts")
.option(
"--exclude-addons <addons>",
"default addons to exclude (comma-separated)",
)
// Screen and window
.option("--screen-min-width <width>", "minimum screen width", parseInt)
.option("--screen-max-width <width>", "maximum screen width", parseInt)
.option("--screen-min-height <height>", "minimum screen height", parseInt)
.option("--screen-max-height <height>", "maximum screen height", parseInt)
.option("--window-width <width>", "fixed window width", parseInt)
.option("--window-height <height>", "fixed window height", parseInt)
// Advanced options
.option("--ff-version <version>", "Firefox version to emulate", parseInt)
.option("--main-world-eval", "enable main world script evaluation")
.option("--webgl-vendor <vendor>", "WebGL vendor string")
.option("--webgl-renderer <renderer>", "WebGL renderer string")
// Fingerprint
.option(
"--fingerprint <fingerprint>",
"custom BrowserForge fingerprint (JSON string)",
)
// Proxy
.option(
"--proxy <proxy>",
"proxy URL (protocol://[username:password@]host:port)",
)
// Cache and performance
.option("--disable-cache", "disable browser cache (cache enabled by default)")
// Environment and debugging
.option("--virtual-display <display>", "virtual display number (e.g., :99)")
.option("--debug", "enable debug output")
.option("--args <args>", "additional browser arguments (comma-separated)")
.option("--env <env>", "environment variables (JSON string)")
// Firefox preferences
.option("--firefox-prefs <prefs>", "Firefox user preferences (JSON string)")
.description("launch and manage Camoufox browser orchestrator instances")
.action(async (action: string, options: any) => {
try {
if (action === "launch") {
// Validate required options
if (!options.executablePath || !options.profilePath) {
console.error(
"Error: --executable-path and --profile-path are required for launch",
);
process.exit(1);
return;
}
// Build Camoufox options
const camoufoxOptions: any = {
enable_cache: !options.disableCache, // Cache enabled by default as requested
};
// OS fingerprinting
if (options.os) {
camoufoxOptions.os = options.os.includes(",")
? options.os.split(",")
: options.os;
}
// Set geolocation from individual latitude/longitude values
if (options.latitude && options.longitude) {
camoufoxOptions.geolocation = {
latitude: options.latitude,
longitude: options.longitude,
};
}
// Set timezone and country only if explicitly provided
if (options.country) camoufoxOptions.country = options.country;
if (options.timezone) camoufoxOptions.timezone = options.timezone;
// Blocking options
if (options.blockImages) camoufoxOptions.block_images = true;
if (options.blockWebrtc) camoufoxOptions.block_webrtc = true;
if (options.blockWebgl) camoufoxOptions.block_webgl = true;
// Security options
if (options.disableCoop) camoufoxOptions.disable_coop = true;
// Geolocation IP
if (options.geoip) {
camoufoxOptions.geoip =
options.geoip === "auto" ? true : options.geoip;
}
// UI and behavior
if (options.humanize) camoufoxOptions.humanize = options.humanize;
if (options.headless) camoufoxOptions.headless = true;
// Localization
if (options.locale) {
camoufoxOptions.locale = options.locale.includes(",")
? options.locale.split(",")
: [options.locale];
}
// Extensions and fonts
if (options.addons) camoufoxOptions.addons = options.addons.split(",");
if (options.fonts) camoufoxOptions.fonts = options.fonts.split(",");
if (options.customFontsOnly) camoufoxOptions.custom_fonts_only = true;
if (options.excludeAddons) {
// Only support UBO for now as that's what camoufox-js supports
const excludeList = options.excludeAddons.split(",");
if (excludeList.includes("UBO") || excludeList.includes("ubo")) {
camoufoxOptions.exclude_addons = ["UBO"];
}
}
// Screen dimensions - combine into screen object
if (
options.screenMinWidth ||
options.screenMaxWidth ||
options.screenMinHeight ||
options.screenMaxHeight
) {
camoufoxOptions.screen = {};
if (options.screenMinWidth)
camoufoxOptions.screen.minWidth = options.screenMinWidth;
if (options.screenMaxWidth)
camoufoxOptions.screen.maxWidth = options.screenMaxWidth;
if (options.screenMinHeight)
camoufoxOptions.screen.minHeight = options.screenMinHeight;
if (options.screenMaxHeight)
camoufoxOptions.screen.maxHeight = options.screenMaxHeight;
}
// Window dimensions - combine into window tuple
if (options.windowWidth && options.windowHeight) {
camoufoxOptions.window = [options.windowWidth, options.windowHeight];
}
// Advanced options
if (options.ffVersion) camoufoxOptions.ff_version = options.ffVersion;
if (options.mainWorldEval) camoufoxOptions.main_world_eval = true;
// WebGL - combine vendor and renderer into webgl_config tuple
if (options.webglVendor && options.webglRenderer) {
camoufoxOptions.webgl_config = [
options.webglVendor,
options.webglRenderer,
];
}
// Fingerprint
if (options.fingerprint) {
try {
camoufoxOptions.fingerprint = JSON.parse(options.fingerprint);
} catch (e) {
console.error("Invalid JSON for --fingerprint option");
process.exit(1);
return;
}
}
// Proxy
if (options.proxy) camoufoxOptions.proxy = options.proxy;
// Environment and debugging
if (options.virtualDisplay)
camoufoxOptions.virtual_display = options.virtualDisplay;
if (options.debug) camoufoxOptions.debug = true;
if (options.args) camoufoxOptions.args = options.args.split(",");
if (options.env) {
try {
camoufoxOptions.env = JSON.parse(options.env);
} catch (e) {
console.error("Invalid JSON for --env option");
process.exit(1);
return;
}
}
// Firefox preferences
if (options.firefoxPrefs) {
try {
camoufoxOptions.firefox_user_prefs = JSON.parse(
options.firefoxPrefs,
);
} catch (e) {
console.error("Invalid JSON for --firefox-prefs option");
process.exit(1);
return;
}
}
// Launch Camoufox
const config = await launchCamoufox(
options.executablePath,
options.profilePath,
camoufoxOptions,
options.url,
);
// Output the configuration as JSON for the Rust side to parse
console.log(
JSON.stringify({
id: config.id,
pid: config.pid,
executable_path: config.executablePath,
profile_path: config.profilePath,
url: config.url,
}),
);
process.exit(0);
} else if (action === "stop") {
if (!options.id) {
console.error("Error: --id is required for stop action");
process.exit(1);
return;
}
const success = await stopCamoufox(options.id);
console.log(JSON.stringify({ success }));
process.exit(0);
} else if (action === "list") {
const processes = await listCamoufoxProcesses();
// The processes already have snake_case properties, no conversion needed
console.log(JSON.stringify(processes));
process.exit(0);
} else if (action === "open-url") {
if (!options.id || !options.url) {
console.error(
"Error: --id and --url are required for open-url action",
);
process.exit(1);
return;
}
// This would require implementing URL opening in existing instance
// For now, we'll return an error as this feature would need additional implementation
console.error("open-url action is not yet implemented");
process.exit(1);
} else {
console.error(
"Invalid action. Use 'launch', 'stop', 'list', or 'open-url'",
);
process.exit(1);
}
} catch (error: unknown) {
console.error(
`Camoufox command failed: ${error instanceof Error ? error.message : JSON.stringify(error)}`,
);
process.exit(1);
}
});
program.parse();
+124
View File
@@ -0,0 +1,124 @@
import { spawn } from "node:child_process";
import path from "node:path";
import getPort from "get-port";
import {
type ProxyConfig,
deleteProxyConfig,
generateProxyId,
getProxyConfig,
isProcessRunning,
listProxyConfigs,
saveProxyConfig,
} from "./proxy-storage";
/**
* Start a proxy in a separate process
* @param upstreamUrl The upstream proxy URL
* @param options Optional configuration
* @returns Promise resolving to the proxy configuration
*/
export async function startProxyProcess(
upstreamUrl: string,
options: { port?: number; ignoreProxyCertificate?: boolean } = {},
): Promise<ProxyConfig> {
// Generate a unique ID for this proxy
const id = generateProxyId();
// Get a random available port if not specified
const port = options.port ?? (await getPort());
// Create the proxy configuration
const config: ProxyConfig = {
id,
upstreamUrl,
localPort: port,
ignoreProxyCertificate: options.ignoreProxyCertificate ?? false,
};
// Save the configuration before starting the process
saveProxyConfig(config);
// Build the command arguments
const args = [
path.join(__dirname, "index.js"),
"proxy-worker",
"start",
"--id",
id,
];
// Spawn the process with proper detachment
const child = spawn(process.execPath, args, {
detached: true,
stdio: ["ignore", "ignore", "ignore"], // Completely ignore all stdio
cwd: process.cwd(),
});
// Unref the child to allow the parent to exit independently
child.unref();
// Store the process ID and local URL
config.pid = child.pid;
config.localUrl = `http://127.0.0.1:${port}`;
// Update the configuration with the process ID
saveProxyConfig(config);
// Give the worker a moment to start before returning
await new Promise((resolve) => setTimeout(resolve, 100));
return config;
}
/**
* Stop a proxy process
* @param id The proxy ID to stop
* @returns Promise resolving to true if stopped, false if not found
*/
export async function stopProxyProcess(id: string): Promise<boolean> {
const config = getProxyConfig(id);
if (!config?.pid) {
// Try to delete the config anyway in case it exists without a PID
deleteProxyConfig(id);
return false;
}
try {
// Check if the process is running
if (isProcessRunning(config.pid)) {
// Send SIGTERM to the process
process.kill(config.pid, "SIGTERM");
// Wait a bit to ensure the process has terminated
await new Promise((resolve) => setTimeout(resolve, 500));
// If still running, send SIGKILL
if (isProcessRunning(config.pid)) {
process.kill(config.pid, "SIGKILL");
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
// Delete the configuration
deleteProxyConfig(id);
return true;
} catch (error) {
console.error(`Error stopping proxy ${id}:`, error);
// Delete the configuration even if stopping failed
deleteProxyConfig(id);
return false;
}
}
/**
* Stop all proxy processes
* @returns Promise resolving when all proxies are stopped
*/
export async function stopAllProxyProcesses(): Promise<void> {
const configs = listProxyConfigs();
const stopPromises = configs.map((config) => stopProxyProcess(config.id));
await Promise.all(stopPromises);
}
+150
View File
@@ -0,0 +1,150 @@
import fs from "node:fs";
import path from "node:path";
import tmp from "tmp";
export interface ProxyConfig {
id: string;
upstreamUrl: string;
localPort?: number;
ignoreProxyCertificate?: boolean;
localUrl?: string;
pid?: number;
}
const STORAGE_DIR = path.join(tmp.tmpdir, "donutbrowser", "proxies");
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
/**
* Save a proxy configuration to disk
* @param config The proxy configuration to save
*/
export function saveProxyConfig(config: ProxyConfig): void {
const filePath = path.join(STORAGE_DIR, `${config.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(config, null, 2));
}
/**
* Get a proxy configuration by ID
* @param id The proxy ID
* @returns The proxy configuration or null if not found
*/
export function getProxyConfig(id: string): ProxyConfig | null {
const filePath = path.join(STORAGE_DIR, `${id}.json`);
if (!fs.existsSync(filePath)) {
return null;
}
try {
const content = fs.readFileSync(filePath, "utf-8");
return JSON.parse(content) as ProxyConfig;
} catch (error) {
console.error(`Error reading proxy config ${id}:`, error);
return null;
}
}
/**
* Delete a proxy configuration
* @param id The proxy ID to delete
* @returns True if deleted, false if not found
*/
export function deleteProxyConfig(id: string): boolean {
const filePath = path.join(STORAGE_DIR, `${id}.json`);
if (!fs.existsSync(filePath)) {
return false;
}
try {
fs.unlinkSync(filePath);
return true;
} catch (error) {
console.error(`Error deleting proxy config ${id}:`, error);
return false;
}
}
/**
* List all saved proxy configurations
* @returns Array of proxy configurations
*/
export function listProxyConfigs(): ProxyConfig[] {
if (!fs.existsSync(STORAGE_DIR)) {
return [];
}
try {
return fs
.readdirSync(STORAGE_DIR)
.filter((file) => file.endsWith(".json"))
.map((file) => {
try {
const content = fs.readFileSync(
path.join(STORAGE_DIR, file),
"utf-8",
);
return JSON.parse(content) as ProxyConfig;
} catch (error) {
console.error(`Error reading proxy config ${file}:`, error);
return null;
}
})
.filter((config): config is ProxyConfig => config !== null);
} catch (error) {
console.error("Error listing proxy configs:", error);
return [];
}
}
/**
* Update a proxy configuration
* @param config The proxy configuration to update
* @returns True if updated, false if not found
*/
export function updateProxyConfig(config: ProxyConfig): boolean {
const filePath = path.join(STORAGE_DIR, `${config.id}.json`);
try {
fs.readFileSync(filePath, "utf-8");
fs.writeFileSync(filePath, JSON.stringify(config, null, 2));
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
console.error(
`Config ${config.id} was deleted while the app was running`,
);
return false;
}
console.error(`Error updating proxy config ${config.id}:`, error);
return false;
}
}
/**
* Check if a proxy process is running
* @param pid The process ID to check
* @returns True if running, false otherwise
*/
export function isProcessRunning(pid: number): boolean {
try {
// The kill method with signal 0 doesn't actually kill the process
// but checks if it exists
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
/**
* Generate a unique ID for a proxy
* @returns A unique ID string
*/
export function generateProxyId(): string {
return `proxy_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
}
+75
View File
@@ -0,0 +1,75 @@
import { Server } from "proxy-chain";
import { getProxyConfig, updateProxyConfig } from "./proxy-storage";
/**
* Run a proxy server as a worker process
* @param id The proxy configuration ID
*/
export async function runProxyWorker(id: string): Promise<void> {
// Get the proxy configuration
const config = getProxyConfig(id);
if (!config) {
console.error(`Proxy configuration ${id} not found`);
process.exit(1);
}
// Create a new proxy server
const server = new Server({
port: config.localPort,
host: "127.0.0.1",
prepareRequestFunction: () => {
return {
upstreamProxyUrl: config.upstreamUrl,
ignoreUpstreamProxyCertificate: config.ignoreProxyCertificate ?? false,
};
},
});
// Handle process termination gracefully
const gracefulShutdown = async (signal: string) => {
console.log(`Proxy worker ${id} received ${signal}, shutting down...`);
try {
await server.close(true);
console.log(`Proxy worker ${id} shut down successfully`);
} catch (error) {
console.error(`Error during shutdown for proxy ${id}:`, error);
}
process.exit(0);
};
process.on("SIGTERM", () => void gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => void gracefulShutdown("SIGINT"));
// Handle uncaught exceptions
process.on("uncaughtException", (error) => {
console.error(`Uncaught exception in proxy worker ${id}:`, error);
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
console.error(`Unhandled rejection in proxy worker ${id}:`, reason);
process.exit(1);
});
// Start the server
try {
await server.listen();
// Update the config with the actual port (in case it was auto-assigned)
config.localPort = server.port;
config.localUrl = `http://127.0.0.1:${server.port}`;
updateProxyConfig(config);
console.log(`Proxy worker ${id} started on port ${server.port}`);
console.log(`Forwarding to upstream proxy: ${config.upstreamUrl}`);
// Keep the process alive
setInterval(() => {
// Do nothing, just keep the process alive
}, 60000);
} catch (error) {
console.error(`Failed to start proxy worker ${id}:`, error);
process.exit(1);
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "CommonJS",
"lib": ["dom", "es6", "es2017", "esnext.asynciterable"],
"sourceMap": false,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"types": ["node"],
"esModuleInterop": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"baseUrl": ".",
"allowSyntheticDefaultImports": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"removeComments": true
}
}
+47 -75
View File
@@ -2,110 +2,82 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.25.1",
"version": "0.7.1",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"test": "pnpm test:rust:unit && pnpm test:sync-e2e",
"test": "pnpm test:rust",
"test:rust": "cd src-tauri && cargo test",
"test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration",
"test:sync-e2e": "node scripts/sync-test-harness.mjs",
"lint": "pnpm lint:js && pnpm lint:rust && pnpm lint:spell",
"lint:js": "biome check src/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
"lint": "pnpm lint:js && pnpm lint:rust",
"lint:js": "biome check src/ && tsc --noEmit",
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"lint:spell": "typos .",
"tauri": "node scripts/run-with-env.mjs tauri",
"tauri": "tauri",
"shadcn:add": "pnpm dlx shadcn@latest add",
"prepare": "husky && husky install",
"format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"format:js": "biome check src/ --write --unsafe && cd donut-sync && biome check src/ --write --unsafe",
"format:js": "biome check src/ --write --unsafe",
"format": "pnpm format:js && pnpm format:rust",
"build:sync": "cd donut-sync && pnpm build",
"cargo": "cd src-tauri && cargo",
"unused-exports:js": "ts-unused-exports tsconfig.json",
"check-unused-commands": "cd src-tauri && cargo test test_no_unused_tauri_commands",
"copy-proxy-binary": "node src-tauri/copy-proxy-binary.mjs",
"prebuild": "pnpm copy-proxy-binary",
"pretauri:dev": "pnpm copy-proxy-binary",
"precargo": "pnpm copy-proxy-binary"
"check-unused-commands": "cd src-tauri && cargo run --bin check_unused_commands"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-portal": "^1.1.10",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"@tauri-apps/api": "~2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-deep-link": "^2.4.9",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "~2.5.1",
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.5.4",
"ahooks": "^3.9.7",
"canvas-confetti": "^1.9.4",
"@tauri-apps/api": "^2.6.0",
"@tauri-apps/plugin-deep-link": "^2.4.0",
"@tauri-apps/plugin-dialog": "^2.3.0",
"@tauri-apps/plugin-fs": "~2.4.0",
"@tauri-apps/plugin-opener": "^2.4.0",
"ahooks": "^3.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"framer-motion": "^12.38.0",
"i18next": "^26.1.0",
"lucide-react": "^1.14.0",
"motion": "^12.38.0",
"next": "^16.2.6",
"next": "^15.3.5",
"next-themes": "^0.4.6",
"onborda": "^1.2.5",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.7",
"react-icons": "^5.6.0",
"recharts": "3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"tauri-plugin-macos-permissions-api": "^2.3.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.15",
"@tailwindcss/postcss": "^4.3.0",
"@tauri-apps/cli": "~2.11.1",
"@types/canvas-confetti": "^1.9.0",
"@types/color": "^4.2.1",
"@types/node": "^25.7.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@biomejs/biome": "2.0.6",
"@tailwindcss/postcss": "^4.1.11",
"@tauri-apps/cli": "^2.6.2",
"@types/node": "^24.0.11",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"husky": "^9.1.7",
"lint-staged": "^17.0.4",
"tailwindcss": "^4.3.0",
"lint-staged": "^16.1.2",
"tailwindcss": "^4.1.11",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.3"
"tw-animate-css": "^1.3.5",
"typescript": "~5.8.3"
},
"packageManager": "pnpm@11.2.2",
"packageManager": "pnpm@10.11.1",
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css}": [
"**/*.{js,jsx,ts,tsx,json,css,md}": [
"biome check --fix"
],
"src-tauri/**/*.rs": [
"bash -c 'cd src-tauri && cargo fmt --all'",
"bash -c 'cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all'",
"bash -c 'cd src-tauri && cargo test --lib'"
],
"**/*.{rs,ts,tsx,js,jsx,md}": [
"typos"
"cd src-tauri && cargo fmt --all",
"cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all",
"cd src-tauri && cargo test"
]
}
}
+2365 -8319
View File
File diff suppressed because it is too large Load Diff
-26
View File
@@ -1,35 +1,9 @@
packages:
- nodecar
- donut-sync
onlyBuiltDependencies:
- '@biomejs/biome'
- '@nestjs/core'
- '@tailwindcss/oxide'
- better-sqlite3
- esbuild
- 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

Some files were not shown because too many files have changed in this diff Show More