Compare commits

...

9 Commits

Author SHA1 Message Date
zhom e006d56387 chore: version bump 2026-06-01 23:34:21 +04:00
zhom 43f9f02029 chore: update issue validation 2026-06-01 18:05:59 +04:00
zhom 839265de35 chore: cleanup windows ci 2026-06-01 17:37:18 +04:00
zhom 0d85b61c96 chore: add missing keys 2026-06-01 15:09:59 +04:00
andy f581b6ec59 Merge pull request #395 from huy97/feature/add-vietnamese-locale
feat(i18n): add Vietnamese (vi) locale
2026-05-31 16:36:33 -07:00
andy 43c86c2dfb Merge branch 'main' into feature/add-vietnamese-locale 2026-05-31 16:36:22 -07:00
zhom 42067367fd chore: version bump 2026-06-01 02:19:25 +04:00
zhom ce7213dccd chore: bump flake.lock 2026-06-01 01:41:12 +04:00
huy97 ddfdf68dd1 feat(i18n): add Vietnamese (vi) locale
Add Vietnamese translation with all 1572 keys matching en.json.
Register "vi" in SUPPORTED_LANGUAGES, LANGUAGE_FALLBACKS, and
settings dialog language type assertion. Update locale count
from 8 to 9 in docs (AGENTS.md, CONTRIBUTING.md) to account
for previously-omitted Korean (ko).
2026-05-30 23:13:08 +07:00
16 changed files with 2102 additions and 463 deletions
-108
View File
@@ -1,108 +0,0 @@
name: Compliance Close
on:
schedule:
# Every 30 minutes; the actual close decision uses comment age, so the cron
# cadence only bounds how stale the closure can get past the 24-hour mark.
- cron: "*/30 * * * *"
workflow_dispatch:
permissions:
contents: read
issues: write
pull-requests: write
jobs:
close-non-compliant:
if: github.repository == 'zhom/donutbrowser'
runs-on: ubuntu-latest
steps:
- name: Close non-compliant issues and PRs after 24 hours
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { data: items } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
labels: 'needs:compliance',
state: 'open',
per_page: 100,
});
if (items.length === 0) {
core.info('No open issues/PRs with needs:compliance label');
return;
}
const now = Date.now();
const window_ms = 24 * 60 * 60 * 1000;
for (const item of items) {
const isPR = !!item.pull_request;
const kind = isPR ? 'PR' : 'issue';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
});
// Use the OLDEST compliance sentinel as the start of the 24-hour
// window so back-and-forth edits don't reset the clock.
const sentinel = comments
.filter(c => c.body && c.body.includes('<!-- issue-compliance -->'))
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at))[0];
if (!sentinel) {
core.info(`${kind} #${item.number} has needs:compliance label but no compliance comment; skipping`);
continue;
}
const age_ms = now - new Date(sentinel.created_at).getTime();
if (age_ms < window_ms) {
const hours = (age_ms / (60 * 60 * 1000)).toFixed(1);
core.info(`${kind} #${item.number} still within 24-hour window (${hours}h elapsed)`);
continue;
}
const closeMessage = isPR
? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/main/CONTRIBUTING.md) within the 24-hour window.\n\nFeel free to open a new pull request that follows our guidelines.'
: 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/main/CONTRIBUTING.md) within the 24-hour window.\n\nFeel free to open a new issue that follows our issue templates.';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
body: closeMessage,
});
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
name: 'needs:compliance',
});
} catch (e) {
core.info(`Could not remove needs:compliance label from #${item.number}: ${e.message}`);
}
if (isPR) {
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: item.number,
state: 'closed',
});
} else {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: item.number,
state: 'closed',
state_reason: 'not_planned',
});
}
core.info(`Closed non-compliant ${kind} #${item.number} after 24-hour window`);
}
+15 -151
View File
@@ -2,7 +2,7 @@ name: Issue Compliance Check
on:
issues:
types: [opened, edited]
types: [opened]
permissions:
contents: read
@@ -13,7 +13,12 @@ env:
jobs:
check-compliance:
if: github.repository == 'zhom/donutbrowser' && github.event.action == 'opened'
# 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
@@ -44,7 +49,7 @@ jobs:
- 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.
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
{
@@ -83,7 +88,7 @@ jobs:
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/raw.txt
# Strip accidental markdown fences and parse. On parse failure, fall back
# to a noop result so the workflow doesn't fail the issue author's run.
# 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"
@@ -94,6 +99,7 @@ jobs:
cat /tmp/result.json
- name: Build comment
id: build
run: |
python3 - <<'EOF'
import json, os
@@ -103,167 +109,25 @@ jobs:
parts = []
if not compliant:
parts.append('<!-- issue-compliance -->')
parts.append("This issue doesn't fully meet our [contributing guidelines](../blob/main/CONTRIBUTING.md).")
parts.append("This issue was automatically closed because it doesn't follow our [issue templates](../issues/new/choose).")
parts.append('')
parts.append('**What needs to be fixed:**')
parts.append('**What was missing:**')
for reason in reasons:
parts.append(f'- {reason}')
parts.append('')
parts.append('Please edit this issue to address the above within **24 hours**, or it will be automatically closed.')
parts.append('')
parts.append('If you believe this was flagged incorrectly, please let a maintainer know.')
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'has_comment={"true" if comment else "false"}\n')
fh.write(f'non_compliant={"true" if not compliant else "false"}\n')
EOF
id: build
- name: Post comment
if: steps.build.outputs.has_comment == '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
- name: Apply needs:compliance label
- 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 edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "needs:compliance"
recheck-compliance:
# When a flagged issue is edited, re-check. If now compliant: remove label,
# delete the previous compliance comment, and thank the author. If still
# non-compliant: leave label and post an updated note.
if: >
github.repository == 'zhom/donutbrowser' &&
github.event.action == 'edited' &&
contains(github.event.issue.labels.*.name, 'needs:compliance')
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 re-checking a GitHub issue that was previously flagged as not meeting template requirements. 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)
## 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.
## Output schema
{
"is_compliant": true | false,
"non_compliance_reasons": ["short bullet", ...]
}
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: ("Title: " + $title + "\n\nBody:\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
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; assuming still non-compliant"
echo '{"is_compliant": false, "non_compliance_reasons": ["unable to parse model output"]}' > /tmp/result.json
fi
- name: Resolve compliance state
id: resolve
run: |
IS_COMPLIANT=$(jq -r '.is_compliant // false' /tmp/result.json)
echo "is_compliant=$IS_COMPLIANT" >> "$GITHUB_OUTPUT"
- name: Clear compliance label and acknowledge fix
if: steps.resolve.outputs.is_compliant == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --remove-label "needs:compliance" || true
# Delete the previous <!-- issue-compliance --> sentinel comment so
# the thread is clean once the author has addressed the issue.
COMMENT_ID=$(gh api "repos/$GITHUB_REPOSITORY/issues/$ISSUE_NUMBER/comments" \
--jq '[.[] | select(.body | contains("<!-- issue-compliance -->"))][-1].id // empty')
if [ -n "$COMMENT_ID" ]; then
gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID" || true
fi
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" \
--body "Thanks for updating the issue."
- name: Build follow-up comment
if: steps.resolve.outputs.is_compliant != 'true'
run: |
python3 - <<'EOF'
import json
r = json.load(open('/tmp/result.json'))
reasons = r.get('non_compliance_reasons') or []
parts = [
'<!-- issue-compliance -->',
'This issue still does not meet our [contributing guidelines](../blob/main/CONTRIBUTING.md).',
'',
'**What still needs to be fixed:**',
]
for reason in reasons:
parts.append(f'- {reason}')
parts.append('')
parts.append('Please edit this issue to address the above within **24 hours**, or it will be automatically closed.')
open('/tmp/comment.md', 'w').write('\n'.join(parts))
EOF
- name: Post follow-up comment
if: steps.resolve.outputs.is_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"
+23 -170
View File
@@ -23,6 +23,9 @@ jobs:
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:
@@ -40,182 +43,32 @@ jobs:
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
fi
- name: Configure aws-cli for R2
# aws-cli v2.23+ sends integrity checksums by default; Cloudflare R2
# rejects those headers with `Unauthorized` on ListObjectsV2.
# Also normalise the endpoint URL (must start with https://).
# Both values propagate to later steps via $GITHUB_ENV.
env:
RAW_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
run: |
endpoint="$RAW_ENDPOINT"
if [[ "$endpoint" != https://* && "$endpoint" != http://* ]]; then
endpoint="https://$endpoint"
fi
echo "R2_ENDPOINT=$endpoint" >> "$GITHUB_ENV"
echo "AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED" >> "$GITHUB_ENV"
echo "AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_REQUIRED" >> "$GITHUB_ENV"
- name: Install tools
- 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
# Remove pre-installed aws-cli v2 — it sends CRC64NVME checksums
# that Cloudflare R2 rejects with Unauthorized, and the s3transfer
# lib has a confirmed bug where WHEN_REQUIRED is silently ignored
# (boto/s3transfer#327). Install aws-cli v1 via pip instead.
# 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
pip3 install --break-system-packages awscli
# Ensure pip-installed aws is on PATH (pip may install to ~/.local/bin)
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
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: Download packages from GitHub release
- 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 }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
mkdir -p /tmp/packages
gh release download "$TAG" \
--repo "${{ github.repository }}" \
--pattern "*.deb" \
--dir /tmp/packages
gh release download "$TAG" \
--repo "${{ github.repository }}" \
--pattern "*.rpm" \
--dir /tmp/packages
echo "Downloaded packages:"
ls -lh /tmp/packages/
- name: Build DEB repository
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
DEB_DIR="/tmp/repo/deb"
mkdir -p "$DEB_DIR/pool/main"
mkdir -p "$DEB_DIR/dists/stable/main/binary-amd64"
mkdir -p "$DEB_DIR/dists/stable/main/binary-arm64"
# Sync existing pool from R2 (incremental)
aws s3 sync "s3://${R2_BUCKET}/deb/pool" "$DEB_DIR/pool" \
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || true
# Copy new .deb files into pool
cp /tmp/packages/*.deb "$DEB_DIR/pool/main/" 2>/dev/null || true
# Generate Packages and Packages.gz for each arch
for arch in amd64 arm64; do
BINARY_DIR="$DEB_DIR/dists/stable/main/binary-${arch}"
(cd "$DEB_DIR" && dpkg-scanpackages --arch "$arch" pool/main) \
> "$BINARY_DIR/Packages"
gzip -9c "$BINARY_DIR/Packages" > "$BINARY_DIR/Packages.gz"
echo " $arch: $(grep -c '^Package:' "$BINARY_DIR/Packages" 2>/dev/null || echo 0) package(s)"
done
# Generate Release file
{
echo "Origin: Donut Browser"
echo "Label: Donut Browser"
echo "Suite: stable"
echo "Codename: stable"
echo "Architectures: amd64 arm64"
echo "Components: main"
echo "Date: $(date -u '+%a, %d %b %Y %H:%M:%S UTC')"
echo "MD5Sum:"
for arch in amd64 arm64; do
for file in "main/binary-${arch}/Packages" "main/binary-${arch}/Packages.gz"; do
filepath="$DEB_DIR/dists/stable/$file"
if [[ -f "$filepath" ]]; then
size=$(wc -c < "$filepath")
md5=$(md5sum "$filepath" | awk '{print $1}')
printf " %s %8d %s\n" "$md5" "$size" "$file"
fi
done
done
echo "SHA256:"
for arch in amd64 arm64; do
for file in "main/binary-${arch}/Packages" "main/binary-${arch}/Packages.gz"; do
filepath="$DEB_DIR/dists/stable/$file"
if [[ -f "$filepath" ]]; then
size=$(wc -c < "$filepath")
sha256=$(sha256sum "$filepath" | awk '{print $1}')
printf " %s %8d %s\n" "$sha256" "$size" "$file"
fi
done
done
} > "$DEB_DIR/dists/stable/Release"
echo "DEB Release file created."
- name: Build RPM repository
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
RPM_DIR="/tmp/repo/rpm"
mkdir -p "$RPM_DIR/x86_64"
mkdir -p "$RPM_DIR/aarch64"
# Sync existing RPMs from R2 (incremental)
aws s3 sync "s3://${R2_BUCKET}/rpm/x86_64" "$RPM_DIR/x86_64" \
--endpoint-url "$R2_ENDPOINT" --exclude "repodata/*" 2>/dev/null || true
aws s3 sync "s3://${R2_BUCKET}/rpm/aarch64" "$RPM_DIR/aarch64" \
--endpoint-url "$R2_ENDPOINT" --exclude "repodata/*" 2>/dev/null || true
# Copy new .rpm files into arch directories
for rpm in /tmp/packages/*.rpm; do
[[ -f "$rpm" ]] || continue
filename=$(basename "$rpm")
if [[ "$filename" == *x86_64* ]]; then
cp "$rpm" "$RPM_DIR/x86_64/"
elif [[ "$filename" == *aarch64* ]]; then
cp "$rpm" "$RPM_DIR/aarch64/"
fi
done
# Generate repodata
createrepo_c --update "$RPM_DIR"
echo "RPM repodata created."
- name: Upload to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
run: |
echo "Uploading DEB repository..."
aws s3 sync /tmp/repo/deb/dists "s3://${R2_BUCKET}/deb/dists" \
--endpoint-url "$R2_ENDPOINT" --delete
aws s3 sync /tmp/repo/deb/pool "s3://${R2_BUCKET}/deb/pool" \
--endpoint-url "$R2_ENDPOINT"
echo "Uploading RPM repository..."
aws s3 sync /tmp/repo/rpm "s3://${R2_BUCKET}/rpm" \
--endpoint-url "$R2_ENDPOINT"
- name: Verify upload
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
echo "Published repos for $TAG"
echo ""
echo "DEB dists/stable/:"
aws s3 ls "s3://${R2_BUCKET}/deb/dists/stable/" \
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty)"
echo "DEB pool/main/:"
aws s3 ls "s3://${R2_BUCKET}/deb/pool/main/" \
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty)"
echo "RPM repodata/:"
aws s3 ls "s3://${R2_BUCKET}/rpm/repodata/" \
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty)"
run: bash scripts/publish-repo.sh "${{ steps.tag.outputs.tag }}"
+6 -1
View File
@@ -246,7 +246,12 @@ jobs:
# Copy sidecar binaries
cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/"
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.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
+6 -1
View File
@@ -247,7 +247,12 @@ jobs:
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/"
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.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/"
+6 -6
View File
@@ -11,7 +11,7 @@ donutbrowser/
│ ├── 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, pt, ru, zh)
│ ├── 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)
@@ -76,12 +76,12 @@ Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropria
- 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 seven locale files in `src/i18n/locales/` (en, es, fr, ja, pt, ru, zh) — not just `en.json`. The English version alone is incomplete work.
- 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 seven locales, use a one-shot Python script in `/tmp/` that loads each `*.json`, mutates it, and writes it back. Seven sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away.
- 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)
@@ -95,7 +95,7 @@ User-facing errors returned from a Tauri command MUST be JSON `{ "code": "FOO_BA
```
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 seven locale files.
4. Add `backendErrors.fooBar` to all nine locale files.
Raw error strings reach the user untranslated; that's the bug pattern this rule blocks.
@@ -148,7 +148,7 @@ Reference implementations: `proxy-management-dialog.tsx`, `extension-management-
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 seven locales.
- `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.
@@ -158,7 +158,7 @@ Dispatch: the global `keydown` listener and the `runShortcut` callback both live
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 seven locale files.
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.
+9
View File
@@ -1,6 +1,15 @@
# 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
+1 -1
View File
@@ -73,7 +73,7 @@ codeql database analyze /tmp/codeql-rust --format=sarifv2.1.0 --output=/tmp/rust
## Key Rules
- **Translations**: Any UI text changes must be reflected in all 7 locale files (`src/i18n/locales/`)
- **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
Generated
+3 -3
View File
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1767767207,
"narHash": "sha256-Mj3d3PfwltLmukFal5i3fFt27L6NiKXdBezC1EBuZs4=",
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "5912c1772a44e31bf1c63c0390b90501e5026886",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github"
},
"original": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.24.4",
"version": "0.25.1",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
+3 -18
View File
@@ -871,15 +871,6 @@ dependencies = [
"bzip2-sys",
]
[[package]]
name = "bzip2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
dependencies = [
"libbz2-rs-sys",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
@@ -1793,7 +1784,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.24.4"
version = "0.25.1"
dependencies = [
"aes 0.9.1",
"aes-gcm",
@@ -1804,7 +1795,7 @@ dependencies = [
"base64 0.22.1",
"blake3",
"boringtun",
"bzip2 0.6.1",
"bzip2",
"cbc",
"chrono",
"chrono-tz",
@@ -3613,12 +3604,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "libbz2-rs-sys"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]]
name = "libc"
version = "0.2.186"
@@ -9217,7 +9202,7 @@ checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"aes 0.8.4",
"arbitrary",
"bzip2 0.5.2",
"bzip2",
"constant_time_eq 0.3.1",
"crc32fast",
"crossbeam-utils",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.24.4"
version = "0.25.1"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.24.4",
"version": "0.25.1",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+2 -1
View File
@@ -483,7 +483,8 @@ export function SettingsDialog({
| "zh"
| "ja"
| "ko"
| "ru"),
| "ru"
| "vi"),
);
setOriginalLanguage(selectedLanguage);
}
+4
View File
@@ -8,6 +8,7 @@ import ja from "./locales/ja.json";
import ko from "./locales/ko.json";
import pt from "./locales/pt.json";
import ru from "./locales/ru.json";
import vi from "./locales/vi.json";
import zh from "./locales/zh.json";
export const SUPPORTED_LANGUAGES = [
@@ -19,6 +20,7 @@ export const SUPPORTED_LANGUAGES = [
{ code: "ja", name: "Japanese", nativeName: "日本語" },
{ code: "ko", name: "Korean", nativeName: "한국어" },
{ code: "ru", name: "Russian", nativeName: "Русский" },
{ code: "vi", name: "Vietnamese", nativeName: "Tiếng Việt" },
] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]["code"];
@@ -36,6 +38,7 @@ export const LANGUAGE_FALLBACKS: Record<string, string[]> = {
"es-ES": ["es", "en"],
"fr-CA": ["fr", "en"],
"fr-FR": ["fr", "en"],
"vi-VN": ["vi", "en"],
};
export function getLanguageWithFallback(systemLocale: string): string {
@@ -65,6 +68,7 @@ const resources = {
ja: { translation: ja },
ko: { translation: ko },
ru: { translation: ru },
vi: { translation: vi },
};
i18n.use(initReactI18next).init({
File diff suppressed because it is too large Load Diff