mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-11 17:27:54 +02:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2d16c7be1 | |||
| a0244356bf | |||
| 14522c75f6 | |||
| b4624f8e8f | |||
| e5f12884de | |||
| c95b097c93 | |||
| 742b883090 | |||
| 57e068084e | |||
| e006d56387 | |||
| 43f9f02029 | |||
| 839265de35 | |||
| 0d85b61c96 | |||
| f581b6ec59 | |||
| 43c86c2dfb | |||
| ddfdf68dd1 |
@@ -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`);
|
||||
}
|
||||
@@ -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,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,20 @@ 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
|
||||
run: |
|
||||
# Mirror the local/Docker setup from CLAUDE.md exactly: the same apt
|
||||
# packages and the same pip-installed awscli the working local run uses.
|
||||
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.
|
||||
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"
|
||||
aws --version
|
||||
|
||||
- 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 }}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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 1–9 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.
|
||||
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## v0.25.1 (2026-06-01)
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: update issue validation
|
||||
- chore: cleanup windows ci
|
||||
- chore: add missing keys
|
||||
|
||||
|
||||
## 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
@@ -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
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
| | 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) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
@@ -56,15 +56,15 @@ brew install --cask donut
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.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)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_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) |
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut-0.25.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut-0.25.1-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
@@ -149,6 +149,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
<sub><b>yb403</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>
|
||||
<td align="center">
|
||||
<a href="https://github.com/drunkod">
|
||||
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="drunkod"/>
|
||||
@@ -156,6 +163,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
<sub><b>drunkod</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/JorySeverijnse">
|
||||
<img src="https://avatars.githubusercontent.com/u/117462355?v=4" width="100;" alt="JorySeverijnse"/>
|
||||
@@ -163,21 +172,12 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
<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>
|
||||
|
||||
@@ -96,17 +96,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.24.4";
|
||||
releaseVersion = "0.25.1";
|
||||
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=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_amd64.AppImage";
|
||||
hash = "sha256-+wtKVCYUjDgXyL96oCqHC0ekWHIe9pLjn1RLBfWHamA=";
|
||||
}
|
||||
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=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_aarch64.AppImage";
|
||||
hash = "sha256-fEmf8OzYG3XoEHwOVLh1mONDcJEGeW3d4bb3y//6gPs=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.25.0",
|
||||
"version": "0.25.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 12341",
|
||||
|
||||
Generated
+1
-1
@@ -1784,7 +1784,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.25.0"
|
||||
version = "0.25.2"
|
||||
dependencies = [
|
||||
"aes 0.9.1",
|
||||
"aes-gcm",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.25.0"
|
||||
version = "0.25.2"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
|
||||
+27
-28
@@ -586,22 +586,12 @@ pub async fn get_api_server_status() -> Result<Option<u16>, String> {
|
||||
Ok(server_guard.get_port())
|
||||
}
|
||||
|
||||
/// Serialize a browser config (camoufox/wayfern) to JSON for an API response,
|
||||
/// dropping the `fingerprint` field unless the user has an active paid plan.
|
||||
/// Viewing fingerprints is a paid feature, so free users (and unauthenticated
|
||||
/// API/MCP callers) must never receive it. `is_paid` is resolved once per
|
||||
/// handler via `has_active_paid_subscription()`.
|
||||
fn config_to_api_value<T: serde::Serialize>(
|
||||
config: Option<&T>,
|
||||
is_paid: bool,
|
||||
) -> Option<serde_json::Value> {
|
||||
let mut value = serde_json::to_value(config?).ok()?;
|
||||
if !is_paid {
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.remove("fingerprint");
|
||||
}
|
||||
}
|
||||
Some(value)
|
||||
/// Serialize a browser config (camoufox/wayfern) to JSON for an API response.
|
||||
/// Viewing a profile's fingerprint is available to every API caller; only
|
||||
/// editing it (via `update_profile`) and launching/killing profiles
|
||||
/// programmatically require an active paid plan.
|
||||
fn config_to_api_value<T: serde::Serialize>(config: Option<&T>) -> Option<serde_json::Value> {
|
||||
serde_json::to_value(config?).ok()
|
||||
}
|
||||
|
||||
// API Handlers - Profiles
|
||||
@@ -620,9 +610,6 @@ fn config_to_api_value<T: serde::Serialize>(
|
||||
)]
|
||||
async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let is_paid = crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await;
|
||||
match profile_manager.list_profiles() {
|
||||
Ok(profiles) => {
|
||||
let api_profiles: Vec<ApiProfile> = profiles
|
||||
@@ -637,7 +624,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
|
||||
process_id: profile.process_id,
|
||||
last_launch: profile.last_launch,
|
||||
release_type: profile.release_type.clone(),
|
||||
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
|
||||
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref()),
|
||||
group_id: profile.group_id.clone(),
|
||||
tags: profile.tags.clone(),
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
@@ -677,9 +664,6 @@ async fn get_profile(
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let is_paid = crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await;
|
||||
match profile_manager.list_profiles() {
|
||||
Ok(profiles) => {
|
||||
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
|
||||
@@ -694,7 +678,7 @@ async fn get_profile(
|
||||
process_id: profile.process_id,
|
||||
last_launch: profile.last_launch,
|
||||
release_type: profile.release_type.clone(),
|
||||
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
|
||||
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref()),
|
||||
group_id: profile.group_id.clone(),
|
||||
tags: profile.tags.clone(),
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
@@ -730,9 +714,6 @@ async fn create_profile(
|
||||
Json(request): Json<CreateProfileRequest>,
|
||||
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let is_paid = crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await;
|
||||
|
||||
// Parse camoufox config if provided
|
||||
let camoufox_config = if let Some(config) = &request.camoufox_config {
|
||||
@@ -809,7 +790,7 @@ async fn create_profile(
|
||||
process_id: profile.process_id,
|
||||
last_launch: profile.last_launch,
|
||||
release_type: profile.release_type,
|
||||
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
|
||||
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref()),
|
||||
group_id: profile.group_id,
|
||||
tags: profile.tags,
|
||||
is_running: false,
|
||||
@@ -914,6 +895,14 @@ async fn update_profile(
|
||||
}
|
||||
|
||||
if let Some(camoufox_config) = request.camoufox_config {
|
||||
// Editing a profile's fingerprint config is a paid feature everywhere
|
||||
// (GUI, API, MCP). Viewing it is free; mutating it is not.
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
}
|
||||
let config: Result<CamoufoxConfig, _> = serde_json::from_value(camoufox_config);
|
||||
match config {
|
||||
Ok(config) => {
|
||||
@@ -1844,6 +1833,7 @@ async fn open_url_in_profile(
|
||||
responses(
|
||||
(status = 204, description = "Browser process killed successfully"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan required"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
@@ -1856,6 +1846,15 @@ async fn kill_profile(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
// Programmatically launching and stopping profiles is a paid feature; the
|
||||
// run/open-url handlers gate the same way.
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
}
|
||||
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
|
||||
@@ -508,7 +508,7 @@ impl McpServer {
|
||||
},
|
||||
McpTool {
|
||||
name: "run_profile".to_string(),
|
||||
description: "Launch a browser profile with an optional URL".to_string(),
|
||||
description: "Launch a browser profile with an optional URL. Requires an active Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -530,7 +530,7 @@ impl McpServer {
|
||||
},
|
||||
McpTool {
|
||||
name: "kill_profile".to_string(),
|
||||
description: "Stop a running browser profile".to_string(),
|
||||
description: "Stop a running browser profile. Requires an active Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1829,6 +1829,9 @@ impl McpServer {
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
// Launching profiles programmatically is a paid feature.
|
||||
Self::require_paid_subscription("Launching a profile").await?;
|
||||
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -1910,6 +1913,9 @@ impl McpServer {
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
// Stopping profiles programmatically is a paid feature.
|
||||
Self::require_paid_subscription("Killing a profile").await?;
|
||||
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.25.0",
|
||||
"version": "0.25.2",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
@@ -483,7 +483,8 @@ export function SettingsDialog({
|
||||
| "zh"
|
||||
| "ja"
|
||||
| "ko"
|
||||
| "ru"),
|
||||
| "ru"
|
||||
| "vi"),
|
||||
);
|
||||
setOriginalLanguage(selectedLanguage);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1196,8 +1196,8 @@
|
||||
},
|
||||
"fingerprint": {
|
||||
"notSupported": "Fingerprint editing is only available for Camoufox and Wayfern profiles.",
|
||||
"lockedTitle": "Fingerprint is a Pro feature",
|
||||
"lockedDescription": "Viewing and editing a profile's fingerprint requires an active paid plan. Upgrade to unlock fingerprint protection."
|
||||
"lockedTitle": "Viewing & editing the fingerprint is a Pro feature",
|
||||
"lockedDescription": "Fingerprint protection is included on every plan. Viewing and editing a profile's fingerprint values is what requires an active paid plan."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1807,7 +1807,7 @@
|
||||
"cookieDbLocked": "Could not read cookies — the database is locked. Close the browser and try again.",
|
||||
"cookieDbUnavailable": "Could not read cookies — the cookie store is unavailable.",
|
||||
"selfHostedRequiresLogout": "Sign out of your Donut account before configuring a self-hosted server.",
|
||||
"fingerprintRequiresPro": "Fingerprint protection requires an active paid plan.",
|
||||
"fingerprintRequiresPro": "Viewing or editing the fingerprint requires an active paid plan. Protection is included on all plans.",
|
||||
"proxyNotWorking": "The selected proxy isn't working, so the profile wasn't created.",
|
||||
"proxyPaymentRequired": "The selected proxy requires payment (402) — its subscription may have expired — so the profile wasn't created.",
|
||||
"vpnNotWorking": "The selected VPN isn't working, so the profile wasn't created."
|
||||
|
||||
@@ -1196,8 +1196,8 @@
|
||||
},
|
||||
"fingerprint": {
|
||||
"notSupported": "La edición de huellas digitales solo está disponible para perfiles Camoufox y Wayfern.",
|
||||
"lockedTitle": "La huella digital es una función Pro",
|
||||
"lockedDescription": "Ver y editar la huella digital de un perfil requiere un plan de pago activo. Mejora tu plan para desbloquear la protección de huella digital."
|
||||
"lockedTitle": "Ver y editar la huella digital es una función Pro",
|
||||
"lockedDescription": "La protección de huella digital está incluida en todos los planes. Ver y editar los valores de la huella digital de un perfil es lo que requiere un plan de pago activo."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1807,7 +1807,7 @@
|
||||
"cookieDbLocked": "No se pudieron leer las cookies — la base de datos está bloqueada. Cierra el navegador e inténtalo de nuevo.",
|
||||
"cookieDbUnavailable": "No se pudieron leer las cookies — el almacén de cookies no está disponible.",
|
||||
"selfHostedRequiresLogout": "Cierra sesión en tu cuenta de Donut antes de configurar un servidor autoalojado.",
|
||||
"fingerprintRequiresPro": "La protección de huella digital requiere un plan de pago activo.",
|
||||
"fingerprintRequiresPro": "Ver o editar la huella digital requiere un plan de pago activo. La protección está incluida en todos los planes.",
|
||||
"proxyNotWorking": "El proxy seleccionado no funciona, por lo que no se creó el perfil.",
|
||||
"proxyPaymentRequired": "El proxy seleccionado requiere pago (402) —su suscripción puede haber vencido— por lo que no se creó el perfil.",
|
||||
"vpnNotWorking": "La VPN seleccionada no funciona, por lo que no se creó el perfil."
|
||||
|
||||
@@ -1196,8 +1196,8 @@
|
||||
},
|
||||
"fingerprint": {
|
||||
"notSupported": "L’édition des empreintes n’est disponible que pour les profils Camoufox et Wayfern.",
|
||||
"lockedTitle": "L'empreinte est une fonctionnalité Pro",
|
||||
"lockedDescription": "Afficher et modifier l'empreinte d'un profil nécessite un forfait payant actif. Passez à un forfait supérieur pour débloquer la protection contre le fingerprinting."
|
||||
"lockedTitle": "Afficher et modifier l'empreinte est une fonctionnalité Pro",
|
||||
"lockedDescription": "La protection contre le fingerprinting est incluse dans tous les forfaits. C'est l'affichage et la modification des valeurs de l'empreinte d'un profil qui nécessitent un forfait payant actif."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1807,7 +1807,7 @@
|
||||
"cookieDbLocked": "Impossible de lire les cookies — la base de données est verrouillée. Fermez le navigateur et réessayez.",
|
||||
"cookieDbUnavailable": "Impossible de lire les cookies — le magasin de cookies est indisponible.",
|
||||
"selfHostedRequiresLogout": "Déconnectez-vous de votre compte Donut avant de configurer un serveur auto-hébergé.",
|
||||
"fingerprintRequiresPro": "La protection contre le fingerprinting nécessite un forfait payant actif.",
|
||||
"fingerprintRequiresPro": "Afficher ou modifier l'empreinte nécessite un forfait payant actif. La protection est incluse dans tous les forfaits.",
|
||||
"proxyNotWorking": "Le proxy sélectionné ne fonctionne pas, le profil n'a donc pas été créé.",
|
||||
"proxyPaymentRequired": "Le proxy sélectionné requiert un paiement (402) — son abonnement a peut-être expiré — le profil n'a donc pas été créé.",
|
||||
"vpnNotWorking": "Le VPN sélectionné ne fonctionne pas, le profil n'a donc pas été créé."
|
||||
|
||||
@@ -1196,8 +1196,8 @@
|
||||
},
|
||||
"fingerprint": {
|
||||
"notSupported": "フィンガープリント編集は Camoufox / Wayfern プロファイルでのみ利用できます。",
|
||||
"lockedTitle": "フィンガープリントは Pro 機能です",
|
||||
"lockedDescription": "プロファイルのフィンガープリントの表示と編集には有効な有料プランが必要です。アップグレードしてフィンガープリント保護をご利用ください。"
|
||||
"lockedTitle": "フィンガープリントの表示と編集は Pro 機能です",
|
||||
"lockedDescription": "フィンガープリント保護はすべてのプランに含まれています。プロファイルのフィンガープリントの値を表示・編集するには、有効な有料プランが必要です。"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1807,7 +1807,7 @@
|
||||
"cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。",
|
||||
"cookieDbUnavailable": "Cookie を読み取れません — Cookie ストアを利用できません。",
|
||||
"selfHostedRequiresLogout": "セルフホストサーバーを設定する前に Donut アカウントからサインアウトしてください。",
|
||||
"fingerprintRequiresPro": "フィンガープリント保護には有効な有料プランが必要です。",
|
||||
"fingerprintRequiresPro": "フィンガープリントの表示または編集には有効な有料プランが必要です。保護機能はすべてのプランに含まれています。",
|
||||
"proxyNotWorking": "選択したプロキシが機能していないため、プロファイルは作成されませんでした。",
|
||||
"proxyPaymentRequired": "選択したプロキシは支払いが必要です(402)。サブスクリプションが期限切れの可能性があります。そのため、プロファイルは作成されませんでした。",
|
||||
"vpnNotWorking": "選択したVPNが機能していないため、プロファイルは作成されませんでした。"
|
||||
|
||||
@@ -1196,8 +1196,8 @@
|
||||
},
|
||||
"fingerprint": {
|
||||
"notSupported": "핑거프린트 편집은 Camoufox 및 Wayfern 프로필에서만 사용할 수 있습니다.",
|
||||
"lockedTitle": "핑거프린트는 Pro 기능입니다",
|
||||
"lockedDescription": "프로필의 핑거프린트를 보고 편집하려면 활성 유료 요금제가 필요합니다. 업그레이드하여 핑거프린트 보호를 잠금 해제하세요."
|
||||
"lockedTitle": "핑거프린트 보기 및 편집은 Pro 기능입니다",
|
||||
"lockedDescription": "핑거프린트 보호는 모든 요금제에 포함되어 있습니다. 프로필의 핑거프린트 값을 보고 편집하려면 활성 유료 요금제가 필요합니다."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1807,7 +1807,7 @@
|
||||
"cookieDbLocked": "쿠키를 읽을 수 없습니다 — 데이터베이스가 잠겨 있습니다. 브라우저를 닫고 다시 시도하세요.",
|
||||
"cookieDbUnavailable": "쿠키를 읽을 수 없습니다 — 쿠키 저장소를 사용할 수 없습니다.",
|
||||
"selfHostedRequiresLogout": "자체 호스팅 서버를 구성하기 전에 도넛 계정에서 로그아웃하세요.",
|
||||
"fingerprintRequiresPro": "핑거프린트 보호에는 활성 유료 요금제가 필요합니다.",
|
||||
"fingerprintRequiresPro": "핑거프린트를 보거나 편집하려면 활성 유료 요금제가 필요합니다. 보호 기능은 모든 요금제에 포함되어 있습니다.",
|
||||
"proxyNotWorking": "선택한 프록시가 작동하지 않아 프로필이 생성되지 않았습니다.",
|
||||
"proxyPaymentRequired": "선택한 프록시는 결제가 필요합니다(402). 구독이 만료되었을 수 있어 프로필이 생성되지 않았습니다.",
|
||||
"vpnNotWorking": "선택한 VPN이 작동하지 않아 프로필이 생성되지 않았습니다."
|
||||
|
||||
@@ -1196,8 +1196,8 @@
|
||||
},
|
||||
"fingerprint": {
|
||||
"notSupported": "A edição de impressão digital só está disponível para perfis Camoufox e Wayfern.",
|
||||
"lockedTitle": "A impressão digital é um recurso Pro",
|
||||
"lockedDescription": "Visualizar e editar a impressão digital de um perfil requer um plano pago ativo. Faça upgrade para desbloquear a proteção contra fingerprint."
|
||||
"lockedTitle": "Visualizar e editar a impressão digital é um recurso Pro",
|
||||
"lockedDescription": "A proteção contra fingerprint está incluída em todos os planos. Visualizar e editar os valores da impressão digital de um perfil é o que requer um plano pago ativo."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1807,7 +1807,7 @@
|
||||
"cookieDbLocked": "Não foi possível ler os cookies — o banco de dados está bloqueado. Feche o navegador e tente novamente.",
|
||||
"cookieDbUnavailable": "Não foi possível ler os cookies — o repositório de cookies está indisponível.",
|
||||
"selfHostedRequiresLogout": "Saia da sua conta Donut antes de configurar um servidor auto-hospedado.",
|
||||
"fingerprintRequiresPro": "A proteção contra fingerprint requer um plano pago ativo.",
|
||||
"fingerprintRequiresPro": "Visualizar ou editar a impressão digital requer um plano pago ativo. A proteção está incluída em todos os planos.",
|
||||
"proxyNotWorking": "O proxy selecionado não está funcionando, então o perfil não foi criado.",
|
||||
"proxyPaymentRequired": "O proxy selecionado exige pagamento (402) — sua assinatura pode ter expirado — então o perfil não foi criado.",
|
||||
"vpnNotWorking": "A VPN selecionada não está funcionando, então o perfil não foi criado."
|
||||
|
||||
@@ -1196,8 +1196,8 @@
|
||||
},
|
||||
"fingerprint": {
|
||||
"notSupported": "Редактирование отпечатков доступно только для профилей Camoufox и Wayfern.",
|
||||
"lockedTitle": "Отпечаток — функция Pro",
|
||||
"lockedDescription": "Для просмотра и редактирования отпечатка профиля требуется активный платный план. Оформите подписку, чтобы разблокировать защиту от отпечатков."
|
||||
"lockedTitle": "Просмотр и редактирование отпечатка — функция Pro",
|
||||
"lockedDescription": "Защита от отпечатков включена во все планы. Активный платный план требуется именно для просмотра и редактирования значений отпечатка профиля."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1807,7 +1807,7 @@
|
||||
"cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.",
|
||||
"cookieDbUnavailable": "Не удалось прочитать куки — хранилище куки недоступно.",
|
||||
"selfHostedRequiresLogout": "Выйдите из аккаунта Donut, прежде чем настраивать собственный сервер.",
|
||||
"fingerprintRequiresPro": "Для защиты от отпечатков требуется активный платный план.",
|
||||
"fingerprintRequiresPro": "Для просмотра или редактирования отпечатка требуется активный платный план. Защита включена во все планы.",
|
||||
"proxyNotWorking": "Выбранный прокси не работает, поэтому профиль не создан.",
|
||||
"proxyPaymentRequired": "Выбранный прокси требует оплаты (402) — возможно, его подписка истекла — поэтому профиль не создан.",
|
||||
"vpnNotWorking": "Выбранный VPN не работает, поэтому профиль не создан."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1196,8 +1196,8 @@
|
||||
},
|
||||
"fingerprint": {
|
||||
"notSupported": "指纹编辑仅适用于 Camoufox 和 Wayfern 配置文件。",
|
||||
"lockedTitle": "指纹是 Pro 功能",
|
||||
"lockedDescription": "查看和编辑配置文件的指纹需要有效的付费方案。升级后即可解锁指纹保护。"
|
||||
"lockedTitle": "查看和编辑指纹是 Pro 功能",
|
||||
"lockedDescription": "所有方案都包含指纹保护。查看和编辑配置文件的指纹数值才需要有效的付费方案。"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1807,7 +1807,7 @@
|
||||
"cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。",
|
||||
"cookieDbUnavailable": "无法读取 Cookie — Cookie 存储不可用。",
|
||||
"selfHostedRequiresLogout": "在配置自托管服务器之前请先退出 Donut 账户。",
|
||||
"fingerprintRequiresPro": "指纹保护需要有效的付费方案。",
|
||||
"fingerprintRequiresPro": "查看或编辑指纹需要有效的付费方案。所有方案均包含指纹保护。",
|
||||
"proxyNotWorking": "所选代理无法使用,因此未创建配置文件。",
|
||||
"proxyPaymentRequired": "所选代理需要付费(402),其订阅可能已过期,因此未创建配置文件。",
|
||||
"vpnNotWorking": "所选 VPN 无法使用,因此未创建配置文件。"
|
||||
|
||||
Reference in New Issue
Block a user