mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-11 09:17:54 +02:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 202f2c852b | |||
| 5a8864654d | |||
| ba40458216 | |||
| 91e6381ba5 | |||
| 2055108578 | |||
| fc9a00b97d | |||
| 15f3aa03f7 | |||
| 6b31c937ea | |||
| 96e4f22e38 | |||
| ef7af59ef8 | |||
| 3df5bffdf5 | |||
| e98d02a585 | |||
| afa2326584 | |||
| d25d8549e4 | |||
| 662b370ed0 | |||
| b2d16c7be1 | |||
| a0244356bf | |||
| 14522c75f6 | |||
| b4624f8e8f | |||
| e5f12884de | |||
| c95b097c93 | |||
| 742b883090 | |||
| 57e068084e | |||
| e006d56387 | |||
| 43f9f02029 | |||
| 839265de35 | |||
| 0d85b61c96 | |||
| f581b6ec59 | |||
| 43c86c2dfb | |||
| ddfdf68dd1 |
@@ -31,7 +31,7 @@ jobs:
|
||||
build-mode: none
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- name: Contribute List
|
||||
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
|
||||
env:
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@a6f7623b2e2401f485f1eead77ced45bd99b09b0 #v31
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Issue Compliance Check
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -13,11 +13,16 @@ 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
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Gather context
|
||||
env:
|
||||
@@ -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"
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
|
||||
- name: Check if first-time contributor
|
||||
id: check-first-time
|
||||
@@ -479,7 +479,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
|
||||
- name: Check if first-time contributor
|
||||
id: check-first-time
|
||||
@@ -617,10 +617,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@385cb694419f98103af0e8fc6187ddcbcbb6eecb #v1.15.13
|
||||
uses: anomalyco/opencode/github@76c631d198f9ff620e15468e45f3457d50481b57 #v1.16.2
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -23,6 +23,9 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
|
||||
- name: Determine release tag
|
||||
id: tag
|
||||
env:
|
||||
@@ -40,182 +43,35 @@ 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)"
|
||||
# GitHub injects secrets verbatim. If a value was pasted with
|
||||
# surrounding quotes or a trailing newline — the local .env wraps all
|
||||
# four R2_* values in double quotes — it reaches the script malformed:
|
||||
# e.g. an endpoint of https://"host" yields
|
||||
# `Could not connect to the endpoint URL`, and a quoted key yields
|
||||
# `Unauthorized`. The local run is unaffected because publish-repo.sh
|
||||
# sources .env through bash, which strips the quotes; CI has no .env,
|
||||
# so strip here. No-op when the secrets are already clean. The script
|
||||
# itself is intentionally left untouched.
|
||||
strip() { printf '%s' "$1" | tr -d '\r\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/"; }
|
||||
export R2_ACCESS_KEY_ID="$(strip "$R2_ACCESS_KEY_ID")"
|
||||
export R2_SECRET_ACCESS_KEY="$(strip "$R2_SECRET_ACCESS_KEY")"
|
||||
export R2_ENDPOINT_URL="$(strip "$R2_ENDPOINT_URL")"
|
||||
export R2_BUCKET_NAME="$(strip "$R2_BUCKET_NAME")"
|
||||
bash scripts/publish-repo.sh "${{ steps.tag.outputs.tag }}"
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
@@ -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
|
||||
@@ -283,7 +288,7 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -449,7 +454,7 @@ jobs:
|
||||
needs: [release, changelog]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -547,7 +552,7 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
with:
|
||||
ref: main
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
@@ -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/"
|
||||
@@ -279,7 +284,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
|
||||
- name: Generate nightly tag
|
||||
id: tag
|
||||
|
||||
@@ -21,6 +21,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@f8a58b6b53f2279f71eb605f03a4ae4d10608f45 #v1.47.0
|
||||
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 #v1.47.2
|
||||
|
||||
@@ -22,3 +22,6 @@ jobs:
|
||||
stale-pr-label: "stale"
|
||||
days-before-stale: 30
|
||||
days-before-close: 7
|
||||
# Never let the maintainer's own assigned issues go stale or get
|
||||
# closed, regardless of inactivity.
|
||||
exempt-issue-assignees: "zhom"
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v6.0.3
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
|
||||
@@ -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)
|
||||
@@ -27,9 +27,7 @@ donutbrowser/
|
||||
│ │ ├── 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
|
||||
@@ -60,9 +58,8 @@ donutbrowser/
|
||||
|
||||
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 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 `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.
|
||||
|
||||
@@ -76,12 +73,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 +92,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 +145,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 +155,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,78 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## v0.26.0 (2026-06-08)
|
||||
|
||||
### Features
|
||||
|
||||
- add cookie export
|
||||
|
||||
### Refactoring
|
||||
|
||||
- deprecate camoufox
|
||||
- cleanup
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: linting
|
||||
- ci(deps): bump the github-actions group with 3 updates (#421)
|
||||
- chore: update flake.nix for v0.25.3 [skip ci] (#417)
|
||||
|
||||
### Other
|
||||
|
||||
- deps(rust)(deps): bump the rust-dependencies group (#422)
|
||||
|
||||
|
||||
## v0.25.3 (2026-06-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- launch wayfern with proper dimentions for mobile devices
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: update flake.nix for v0.25.2 [skip ci] (#415)
|
||||
|
||||
|
||||
## v0.25.2 (2026-06-02)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- cleanup
|
||||
|
||||
### Documentation
|
||||
|
||||
- update CHANGELOG.md and README.md for v0.25.1 [skip ci] (#412)
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: simplify linux repo publish
|
||||
- chore: version bump
|
||||
- chore: copy
|
||||
- chore: update flake.nix for v0.25.1 [skip ci] (#413)
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
## 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
|
||||
- **Anti-detect Chromium engine** — powered by [Wayfern](https://wayfern.com), 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
|
||||
@@ -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.26.0/Donut_0.26.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_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.26.0/Donut_0.26.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_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.26.0/Donut_0.26.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut-0.26.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut-0.26.0-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
@@ -10,6 +11,13 @@ import type { Request } from "express";
|
||||
import * as jwt from "jsonwebtoken";
|
||||
import type { UserContext } from "./user-context.interface.js";
|
||||
|
||||
/** Constant-time string compare; false on length mismatch (no early return). */
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const ab = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(AuthGuard.name);
|
||||
@@ -37,7 +45,7 @@ export class AuthGuard implements CanActivate {
|
||||
|
||||
// Try SYNC_TOKEN first (self-hosted mode)
|
||||
const expectedToken = this.configService.get<string>("SYNC_TOKEN");
|
||||
if (expectedToken && token === expectedToken) {
|
||||
if (expectedToken && safeEqual(token, expectedToken)) {
|
||||
(request as unknown as Record<string, unknown>).user = {
|
||||
mode: "self-hosted",
|
||||
prefix: "",
|
||||
@@ -55,10 +63,29 @@ export class AuthGuard implements CanActivate {
|
||||
algorithms: ["RS256"],
|
||||
}) as jwt.JwtPayload;
|
||||
|
||||
// Validate the scope claims' SHAPE before trusting them as S3 key
|
||||
// prefixes. An empty/over-broad prefix would make validateKeyAccess
|
||||
// (`key.startsWith(prefix)`) authorize the entire bucket, so a signer
|
||||
// bug or permissive claim must not silently widen scope.
|
||||
const prefix = decoded.prefix || `users/${decoded.sub}/`;
|
||||
if (typeof prefix !== "string" || !/^users\/[^/]+\/$/.test(prefix)) {
|
||||
throw new Error(`Invalid prefix claim: ${String(decoded.prefix)}`);
|
||||
}
|
||||
const teamPrefix =
|
||||
decoded.teamPrefix === undefined || decoded.teamPrefix === null
|
||||
? null
|
||||
: decoded.teamPrefix;
|
||||
if (
|
||||
teamPrefix !== null &&
|
||||
!/^teams\/[^/]+\/$/.test(String(teamPrefix))
|
||||
) {
|
||||
throw new Error(`Invalid teamPrefix claim: ${String(teamPrefix)}`);
|
||||
}
|
||||
|
||||
(request as unknown as Record<string, unknown>).user = {
|
||||
mode: "cloud",
|
||||
prefix: decoded.prefix || `users/${decoded.sub}/`,
|
||||
teamPrefix: decoded.teamPrefix || null,
|
||||
prefix,
|
||||
teamPrefix,
|
||||
profileLimit: decoded.profileLimit || 0,
|
||||
teamProfileLimit: decoded.teamProfileLimit || 0,
|
||||
} satisfies UserContext;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@@ -9,6 +10,13 @@ import {
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { SyncService } from "./sync.service.js";
|
||||
|
||||
/** Constant-time string compare; false on length mismatch. */
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const ab = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
@Controller("v1/internal")
|
||||
export class InternalController {
|
||||
private readonly internalKey: string | undefined;
|
||||
@@ -26,7 +34,7 @@ export class InternalController {
|
||||
@Headers("x-internal-key") key: string,
|
||||
@Body() body: { userId: string; maxProfiles: number },
|
||||
) {
|
||||
if (!this.internalKey || key !== this.internalKey) {
|
||||
if (!this.internalKey || !key || !safeEqual(key, this.internalKey)) {
|
||||
throw new UnauthorizedException("Invalid internal key");
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,29 @@ import type {
|
||||
*/
|
||||
const MANIFEST_KEY = ".donut-sync-manifest";
|
||||
|
||||
/** Max presigned-URL lifetime. The client requests ~1h; never mint a URL that
|
||||
* outlives this, regardless of a (possibly hostile) client-supplied expiresIn. */
|
||||
const MAX_PRESIGN_EXPIRES_IN = 3600;
|
||||
|
||||
/** Clamp a client-supplied expiresIn to a sane positive range. */
|
||||
function clampExpiresIn(requested: number | undefined): number {
|
||||
const v = typeof requested === "number" && requested > 0 ? requested : 3600;
|
||||
return Math.min(v, MAX_PRESIGN_EXPIRES_IN);
|
||||
}
|
||||
|
||||
/** Only this metadata key is meaningful to sync (LWW conflict resolution).
|
||||
* Whitelisting prevents a client from signing arbitrary x-amz-meta-* values. */
|
||||
function sanitizeMetadata(
|
||||
metadata: Record<string, string> | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
if (!metadata) return undefined;
|
||||
const out: Record<string, string> = {};
|
||||
if (typeof metadata["updated-at"] === "string") {
|
||||
out["updated-at"] = metadata["updated-at"];
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SyncService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SyncService.name);
|
||||
@@ -286,16 +309,19 @@ export class SyncService implements OnModuleInit {
|
||||
await this.checkProfileLimit(ctx);
|
||||
}
|
||||
|
||||
const expiresIn = dto.expiresIn || 3600;
|
||||
const expiresIn = clampExpiresIn(dto.expiresIn);
|
||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||
|
||||
// Whitelist metadata to the single key sync relies on, so a client can't
|
||||
// sign arbitrary x-amz-meta-* values into its objects.
|
||||
const metadata = sanitizeMetadata(dto.metadata);
|
||||
const command = new PutCmd({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
ContentType: dto.contentType || "application/octet-stream",
|
||||
// Signed into the presigned URL as `x-amz-meta-*`. The client must send
|
||||
// exactly these headers on the PUT, so we echo them in the response.
|
||||
Metadata: dto.metadata,
|
||||
Metadata: metadata,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
|
||||
@@ -313,6 +339,9 @@ export class SyncService implements OnModuleInit {
|
||||
return {
|
||||
url,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
// Echo the metadata we actually signed so the client sends matching
|
||||
// x-amz-meta-* headers on the PUT (S3 rejects unsigned ones).
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -323,7 +352,7 @@ export class SyncService implements OnModuleInit {
|
||||
const key = this.scopeKey(ctx, dto.key);
|
||||
this.validateKeyAccess(ctx, key);
|
||||
|
||||
const expiresIn = dto.expiresIn || 3600;
|
||||
const expiresIn = clampExpiresIn(dto.expiresIn);
|
||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
@@ -438,7 +467,7 @@ export class SyncService implements OnModuleInit {
|
||||
await this.checkProfileLimit(ctx);
|
||||
}
|
||||
|
||||
const expiresIn = dto.expiresIn || 3600;
|
||||
const expiresIn = clampExpiresIn(dto.expiresIn);
|
||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||
|
||||
const items = await Promise.all(
|
||||
@@ -491,7 +520,7 @@ export class SyncService implements OnModuleInit {
|
||||
dto: PresignDownloadBatchRequestDto,
|
||||
ctx: UserContext,
|
||||
): Promise<PresignDownloadBatchResponseDto> {
|
||||
const expiresIn = dto.expiresIn || 3600;
|
||||
const expiresIn = clampExpiresIn(dto.expiresIn);
|
||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||
|
||||
const items = await Promise.all(
|
||||
|
||||
@@ -96,17 +96,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.24.4";
|
||||
releaseVersion = "0.26.0";
|
||||
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.26.0/Donut_0.26.0_amd64.AppImage";
|
||||
hash = "sha256-uwt8T+BeGf5NTFOj3D1gc8I9wkF02X2bJRpU3Yn5E2E=";
|
||||
}
|
||||
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.26.0/Donut_0.26.0_aarch64.AppImage";
|
||||
hash = "sha256-aLXoN5S+gNQJOXrLrTYeBUAckITcTNJUGTk/ZfGhpJA=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.25.0",
|
||||
"version": "0.26.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 12341",
|
||||
|
||||
Generated
+47
-59
@@ -169,7 +169,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -180,7 +180,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -214,7 +214,7 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -1068,9 +1068,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
@@ -1709,7 +1709,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1784,7 +1784,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
dependencies = [
|
||||
"aes 0.9.1",
|
||||
"aes-gcm",
|
||||
@@ -1838,6 +1838,7 @@ dependencies = [
|
||||
"sha2 0.11.0",
|
||||
"shadowsocks",
|
||||
"smoltcp",
|
||||
"subtle",
|
||||
"sys-locale",
|
||||
"sysinfo",
|
||||
"tar",
|
||||
@@ -2097,7 +2098,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2862,14 +2863,17 @@ name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230"
|
||||
checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3085,7 +3089,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.62.2",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3621,9 +3625,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libfuzzer-sys"
|
||||
version = "0.4.12"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d"
|
||||
checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"cc",
|
||||
@@ -3656,9 +3660,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.38.0"
|
||||
version = "0.38.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a76001fb4daed01e5f2b518aac0b4dc592e7c734da63dbffcf0c64fa612a8d0c"
|
||||
checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
@@ -3688,9 +3692,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.30"
|
||||
version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
dependencies = [
|
||||
"value-bag",
|
||||
]
|
||||
@@ -3910,7 +3914,7 @@ dependencies = [
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4087,7 +4091,7 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
|
||||
dependencies = [
|
||||
"proc-macro-crate 1.3.1",
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
@@ -4447,7 +4451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.45.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5496,9 +5500,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.40.0"
|
||||
version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b3492ea85308705c3a5cc24fb9b9cf77273d30590349070db42991202b214c4"
|
||||
checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"fallible-iterator",
|
||||
@@ -5561,7 +5565,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5636,15 +5640,6 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scc"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
|
||||
dependencies = [
|
||||
"sdd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
@@ -5711,12 +5706,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sdd"
|
||||
version = "3.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
|
||||
|
||||
[[package]]
|
||||
name = "seahash"
|
||||
version = "4.1.0"
|
||||
@@ -5916,9 +5905,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.20.0"
|
||||
version = "3.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
|
||||
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bs58",
|
||||
@@ -5936,9 +5925,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.20.0"
|
||||
version = "3.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
|
||||
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
@@ -5961,24 +5950,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serial_test"
|
||||
version = "3.4.0"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f"
|
||||
checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d"
|
||||
dependencies = [
|
||||
"futures-executor",
|
||||
"futures-util",
|
||||
"log",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"scc",
|
||||
"serial_test_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial_test_derive"
|
||||
version = "3.4.0"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9"
|
||||
checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6230,7 +6218,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6977,10 +6965,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7482,7 +7470,7 @@ dependencies = [
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7554,7 +7542,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7642,9 +7630,9 @@ checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.2"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-vo"
|
||||
@@ -8212,7 +8200,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8738,7 +8726,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9018,9 +9006,9 @@ checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.25.0"
|
||||
version = "0.26.0"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -81,6 +81,7 @@ aes-gcm = "0.10"
|
||||
aes = "0.9"
|
||||
cbc = "0.2"
|
||||
ring = "0.17"
|
||||
subtle = "2"
|
||||
sha2 = "0.11"
|
||||
shadowsocks = { version = "1.24", default-features = false, features = ["aead-cipher"] }
|
||||
hyper = { version = "1.10", features = ["full"] }
|
||||
|
||||
+164
-36
@@ -58,13 +58,25 @@ pub struct ApiProfileResponse {
|
||||
pub struct CreateProfileRequest {
|
||||
pub name: String,
|
||||
pub browser: String,
|
||||
pub version: String,
|
||||
/// Optional. Omit (or pass `"latest"`) to use the newest already-downloaded
|
||||
/// version of the chosen browser. A concrete version must already be
|
||||
/// downloaded; the create path does not fetch new versions.
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
pub proxy_id: Option<String>,
|
||||
pub vpn_id: Option<String>,
|
||||
pub launch_hook: Option<String>,
|
||||
pub release_type: Option<String>,
|
||||
/// Camoufox fingerprint/config. Send only when `browser` is `"camoufox"`.
|
||||
/// Omit it, or pass an empty object `{}`, to have a fresh fingerprint
|
||||
/// generated automatically at creation. Provide a `fingerprint` field to
|
||||
/// pin a specific one.
|
||||
#[schema(value_type = Object)]
|
||||
pub camoufox_config: Option<serde_json::Value>,
|
||||
/// Wayfern fingerprint/config. Send only when `browser` is `"wayfern"`.
|
||||
/// Omit it, or pass an empty object `{}`, to have a fresh fingerprint
|
||||
/// generated automatically at creation. Provide a `fingerprint` field to
|
||||
/// pin a specific one.
|
||||
#[schema(value_type = Object)]
|
||||
pub wayfern_config: Option<serde_json::Value>,
|
||||
pub group_id: Option<String>,
|
||||
@@ -74,7 +86,9 @@ pub struct CreateProfileRequest {
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateProfileRequest {
|
||||
pub name: Option<String>,
|
||||
pub browser: Option<String>,
|
||||
// No `browser` field: a profile's engine is fixed at creation (changing it
|
||||
// would invalidate the generated fingerprint and on-disk profile dir).
|
||||
// Accepting it here only to silently ignore it misled API clients.
|
||||
pub version: Option<String>,
|
||||
pub proxy_id: Option<String>,
|
||||
pub vpn_id: Option<String>,
|
||||
@@ -405,6 +419,9 @@ impl ApiServer {
|
||||
let api = ApiDoc::openapi();
|
||||
|
||||
let v1_routes = v1_routes
|
||||
// Inert chokepoint (innermost → runs after auth) for the future per-hour
|
||||
// automation request limit. See rate_limit_middleware.
|
||||
.layer(middleware::from_fn(rate_limit_middleware))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
auth_middleware,
|
||||
@@ -508,8 +525,14 @@ async fn auth_middleware(
|
||||
}
|
||||
};
|
||||
|
||||
// Compare tokens
|
||||
if token != stored_token {
|
||||
// Constant-time comparison so the auth check doesn't leak the shared-prefix
|
||||
// length via timing. `ConstantTimeEq` on equal-length byte slices; differing
|
||||
// lengths simply compare unequal.
|
||||
use subtle::ConstantTimeEq;
|
||||
let token_bytes = token.as_bytes();
|
||||
let stored_bytes = stored_token.as_bytes();
|
||||
let matches = token_bytes.len() == stored_bytes.len() && token_bytes.ct_eq(stored_bytes).into();
|
||||
if !matches {
|
||||
log::warn!("[api] Rejected {path}: token mismatch");
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
@@ -550,6 +573,20 @@ async fn request_logging_middleware(request: axum::extract::Request, next: Next)
|
||||
response
|
||||
}
|
||||
|
||||
/// Chokepoint for the future per-hour automation request limit. The limit
|
||||
/// (`requests_per_hour`, default 100) is already plumbed through entitlements;
|
||||
/// this middleware is intentionally inert today — it resolves the limit but
|
||||
/// never blocks. To enforce, count authenticated requests per rolling hour and
|
||||
/// return `StatusCode::TOO_MANY_REQUESTS` once the limit (when > 0) is exceeded.
|
||||
async fn rate_limit_middleware(
|
||||
request: axum::extract::Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let _requests_per_hour = crate::cloud_auth::CLOUD_AUTH.requests_per_hour().await;
|
||||
// TODO(rate-limit): enforce `_requests_per_hour` for automation routes.
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
// Global API server instance
|
||||
lazy_static! {
|
||||
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
|
||||
@@ -586,22 +623,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 +647,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 +661,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 +701,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 +715,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
|
||||
@@ -710,14 +731,24 @@ async fn get_profile(
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a profile.
|
||||
///
|
||||
/// - `browser` must be `"wayfern"` or `"camoufox"`; any other value is rejected
|
||||
/// with 400.
|
||||
/// - `version` is optional: omit it or pass `"latest"` to use the newest
|
||||
/// already-downloaded version of that browser. The version must be present
|
||||
/// locally (this endpoint does not download new versions); 400 if none is.
|
||||
/// - Omitting the matching `wayfern_config`/`camoufox_config`, or passing an
|
||||
/// empty object `{}`, generates a fresh fingerprint automatically.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/profiles",
|
||||
request_body = CreateProfileRequest,
|
||||
responses(
|
||||
(status = 200, description = "Profile created successfully", body = ApiProfileResponse),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 400, description = "Invalid browser, or no downloaded version available"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Selected proxy requires payment"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
@@ -730,9 +761,34 @@ 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;
|
||||
|
||||
// Only Wayfern and Camoufox profiles are launchable; the rest of the system
|
||||
// (fingerprint generation, launch, run) supports nothing else. Reject anything
|
||||
// else up front — otherwise the profile is created with no fingerprint and an
|
||||
// unrecognized browser, then crashes with a 500 on /run. Mirrors the MCP
|
||||
// create_profile validation.
|
||||
if request.browser != "wayfern" && request.browser != "camoufox" {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Resolve the version. Omitted, empty, or "latest" means "newest version
|
||||
// already downloaded for this browser". The create path generates the
|
||||
// fingerprint by launching that binary, so the version must be present
|
||||
// locally — we don't fetch new versions here. 400 if none is downloaded.
|
||||
let version = match request.version.as_deref() {
|
||||
Some(v) if !v.is_empty() && v != "latest" => v.to_string(),
|
||||
_ => {
|
||||
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
let mut versions = registry.get_downloaded_versions(&request.browser);
|
||||
// browsers is a HashMap, so keys are unordered — sort newest-first by
|
||||
// semver before taking the latest.
|
||||
versions.sort_by(|a, b| crate::api_client::compare_versions(b, a));
|
||||
match versions.into_iter().next() {
|
||||
Some(v) => v,
|
||||
None => return Err(StatusCode::BAD_REQUEST),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Parse camoufox config if provided
|
||||
let camoufox_config = if let Some(config) = &request.camoufox_config {
|
||||
@@ -766,7 +822,7 @@ async fn create_profile(
|
||||
&state.app_handle,
|
||||
&request.name,
|
||||
&request.browser,
|
||||
&request.version,
|
||||
&version,
|
||||
request.release_type.as_deref().unwrap_or("stable"),
|
||||
request.proxy_id.clone(),
|
||||
request.vpn_id.clone(),
|
||||
@@ -809,7 +865,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 +970,14 @@ async fn update_profile(
|
||||
}
|
||||
|
||||
if let Some(camoufox_config) = request.camoufox_config {
|
||||
// Editing a profile's fingerprint config is part of the cross-OS fingerprint
|
||||
// capability (GUI, API, MCP). Viewing it is free; mutating it is not.
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_cross_os_fingerprints()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
}
|
||||
let config: Result<CamoufoxConfig, _> = serde_json::from_value(camoufox_config);
|
||||
match config {
|
||||
Ok(config) => {
|
||||
@@ -1732,7 +1796,7 @@ async fn run_profile(
|
||||
Json(request): Json<RunProfileRequest>,
|
||||
) -> Result<Json<RunProfileResponse>, StatusCode> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
@@ -1818,7 +1882,7 @@ async fn open_url_in_profile(
|
||||
Json(request): Json<OpenUrlRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
@@ -1844,6 +1908,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 +1921,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
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
}
|
||||
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
@@ -2091,3 +2165,57 @@ async fn refresh_wayfern_token(
|
||||
let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
||||
Ok(Json(WayfernTokenResponse { token }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Removing `browser` from UpdateProfileRequest, and rejecting invalid
|
||||
// `browser` values on create, must NOT make the API reject requests that
|
||||
// carry extra/unknown fields — old clients still send them. serde ignores
|
||||
// unknown fields by default; these tests lock that in so a future
|
||||
// `#[serde(deny_unknown_fields)]` can't silently break compatibility.
|
||||
#[test]
|
||||
fn update_profile_request_ignores_unknown_fields() {
|
||||
// `browser` is no longer a field, plus a wholly unknown field. Both must
|
||||
// be accepted and ignored, not rejected.
|
||||
let json = r#"{"name": "p", "browser": "wayfern", "totally_unknown": 123}"#;
|
||||
let parsed: UpdateProfileRequest =
|
||||
serde_json::from_str(json).expect("unknown fields must be ignored, not rejected");
|
||||
assert_eq!(parsed.name.as_deref(), Some("p"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_profile_request_ignores_unknown_fields() {
|
||||
let json = r#"{"name": "p", "browser": "wayfern", "version": "latest", "future_field": true}"#;
|
||||
let parsed: CreateProfileRequest =
|
||||
serde_json::from_str(json).expect("unknown fields must be ignored, not rejected");
|
||||
assert_eq!(parsed.browser, "wayfern");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_profile_request_allows_omitting_version_and_configs() {
|
||||
// Minimal body: no version, no wayfern_config/camoufox_config. Must
|
||||
// deserialize (version resolves to latest-downloaded at the handler; an
|
||||
// absent config triggers fresh-fingerprint generation).
|
||||
let json = r#"{"name": "p", "browser": "wayfern"}"#;
|
||||
let parsed: CreateProfileRequest =
|
||||
serde_json::from_str(json).expect("version and configs are optional");
|
||||
assert_eq!(parsed.browser, "wayfern");
|
||||
assert!(parsed.version.is_none());
|
||||
assert!(parsed.wayfern_config.is_none());
|
||||
assert!(parsed.camoufox_config.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_profile_browser_validation_matches_supported_engines() {
|
||||
// The handler rejects anything that isn't a launchable engine; this is the
|
||||
// same predicate it uses, kept in lockstep with MCP's create_profile.
|
||||
let is_valid = |b: &str| b == "wayfern" || b == "camoufox";
|
||||
assert!(is_valid("wayfern"));
|
||||
assert!(is_valid("camoufox"));
|
||||
assert!(!is_valid("chromium"));
|
||||
assert!(!is_valid("firefox"));
|
||||
assert!(!is_valid(""));
|
||||
}
|
||||
}
|
||||
|
||||
+189
-23
@@ -21,6 +21,76 @@ use crate::sync;
|
||||
pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com";
|
||||
pub const CLOUD_SYNC_URL: &str = "https://sync.donutbrowser.com";
|
||||
|
||||
/// Default per-hour cap on local automation API / MCP requests. Mirrors the
|
||||
/// backend's DEFAULT_REQUESTS_PER_HOUR. Not enforced yet — see the inert
|
||||
/// rate-limit chokepoints in api_server / mcp_server.
|
||||
const DEFAULT_REQUESTS_PER_HOUR: i64 = 100;
|
||||
|
||||
/// Capability + limit set the account is entitled to, derived from its plan.
|
||||
/// Mirrors `apps/backend/src/plans/entitlements.ts`. Features are gated on these
|
||||
/// flags instead of a single "is paid?" boolean, so a plan like the future
|
||||
/// "starter" tier (cross-OS fingerprints + cloud backup, no automation) is just
|
||||
/// data here.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Entitlements {
|
||||
#[serde(default)]
|
||||
pub active: bool,
|
||||
#[serde(rename = "browserAutomation", default)]
|
||||
pub browser_automation: bool,
|
||||
#[serde(rename = "crossOsFingerprints", default)]
|
||||
pub cross_os_fingerprints: bool,
|
||||
#[serde(rename = "cloudBackup", default)]
|
||||
pub cloud_backup: bool,
|
||||
#[serde(rename = "teamCollaboration", default)]
|
||||
pub team_collaboration: bool,
|
||||
#[serde(rename = "profileLimit", default)]
|
||||
pub profile_limit: i64,
|
||||
#[serde(rename = "requestsPerHour", default)]
|
||||
pub requests_per_hour: i64,
|
||||
}
|
||||
|
||||
/// Local fallback mirror of the backend plan -> capability matrix, used only when
|
||||
/// the server hasn't sent an entitlements object (older cached state / backend).
|
||||
fn derive_entitlements(
|
||||
plan: &str,
|
||||
plan_period: Option<&str>,
|
||||
subscription_status: &str,
|
||||
profile_limit: i64,
|
||||
) -> Entitlements {
|
||||
let active =
|
||||
plan != "free" && (subscription_status == "active" || plan_period == Some("lifetime"));
|
||||
if !active {
|
||||
return Entitlements {
|
||||
active: false,
|
||||
browser_automation: false,
|
||||
cross_os_fingerprints: false,
|
||||
cloud_backup: false,
|
||||
team_collaboration: false,
|
||||
profile_limit: 0,
|
||||
requests_per_hour: 0,
|
||||
};
|
||||
}
|
||||
// pro and any unrecognized paid plan -> pro-level (never team).
|
||||
let (browser_automation, cross_os_fingerprints, cloud_backup, team_collaboration) = match plan {
|
||||
"starter" => (false, true, true, false),
|
||||
"team" | "enterprise" => (true, true, true, true),
|
||||
_ => (true, true, true, false),
|
||||
};
|
||||
Entitlements {
|
||||
active,
|
||||
browser_automation,
|
||||
cross_os_fingerprints,
|
||||
cloud_backup,
|
||||
team_collaboration,
|
||||
profile_limit,
|
||||
requests_per_hour: if browser_automation {
|
||||
DEFAULT_REQUESTS_PER_HOUR
|
||||
} else {
|
||||
0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CloudUser {
|
||||
pub id: String,
|
||||
@@ -56,6 +126,26 @@ pub struct CloudUser {
|
||||
pub device_count: Option<i64>,
|
||||
#[serde(rename = "isPrimaryDevice", default)]
|
||||
pub is_primary_device: Option<bool>,
|
||||
/// Capability/limit set derived from the plan by the backend. `default` (None)
|
||||
/// keeps older login/state payloads deserializing; resolve via `entitlements()`.
|
||||
#[serde(default)]
|
||||
pub entitlements: Option<Entitlements>,
|
||||
}
|
||||
|
||||
impl CloudUser {
|
||||
/// Authoritative entitlements: the server-sent set when present, else derived
|
||||
/// locally from the plan fields (keeps older cached state / backends working).
|
||||
pub fn entitlements(&self) -> Entitlements {
|
||||
if let Some(e) = &self.entitlements {
|
||||
return e.clone();
|
||||
}
|
||||
derive_entitlements(
|
||||
&self.plan,
|
||||
self.plan_period.as_deref(),
|
||||
&self.subscription_status,
|
||||
self.profile_limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -658,39 +748,83 @@ impl CloudAuthManager {
|
||||
state.is_some()
|
||||
}
|
||||
|
||||
pub async fn has_active_paid_subscription(&self) -> bool {
|
||||
/// Resolve this session's entitlements (server-sent or locally derived).
|
||||
pub async fn entitlements(&self) -> Option<Entitlements> {
|
||||
let state = self.state.lock().await;
|
||||
match &*state {
|
||||
Some(auth) => {
|
||||
auth.user.plan != "free"
|
||||
&& (auth.user.subscription_status == "active"
|
||||
|| auth.user.plan_period.as_deref() == Some("lifetime"))
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
state.as_ref().map(|auth| auth.user.entitlements())
|
||||
}
|
||||
|
||||
/// Account is in a paid/active state. Used for the "any active plan" gates
|
||||
/// (sync token, wayfern token); per-feature access uses the capability helpers.
|
||||
pub async fn has_active_paid_subscription(&self) -> bool {
|
||||
self.entitlements().await.map(|e| e.active).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Non-async version that uses try_lock, defaults to false if lock can't be acquired.
|
||||
pub fn has_active_paid_subscription_sync(&self) -> bool {
|
||||
match self.state.try_lock() {
|
||||
Ok(state) => match &*state {
|
||||
Some(auth) => {
|
||||
auth.user.plan != "free"
|
||||
&& (auth.user.subscription_status == "active"
|
||||
|| auth.user.plan_period.as_deref() == Some("lifetime"))
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
Ok(state) => state
|
||||
.as_ref()
|
||||
.map(|auth| auth.user.entitlements().active)
|
||||
.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Launch/drive profiles programmatically (local API + MCP automation).
|
||||
pub async fn can_use_browser_automation(&self) -> bool {
|
||||
self
|
||||
.entitlements()
|
||||
.await
|
||||
.map(|e| e.browser_automation)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Edit fingerprints / use a non-native OS fingerprint.
|
||||
pub async fn can_use_cross_os_fingerprints(&self) -> bool {
|
||||
self
|
||||
.entitlements()
|
||||
.await
|
||||
.map(|e| e.cross_os_fingerprints)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Cloud profile sync / backup (async).
|
||||
pub async fn can_use_cloud_backup(&self) -> bool {
|
||||
self
|
||||
.entitlements()
|
||||
.await
|
||||
.map(|e| e.cloud_backup)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Cloud profile sync / backup (non-async, try_lock; false if unavailable).
|
||||
pub fn can_use_cloud_backup_sync(&self) -> bool {
|
||||
match self.state.try_lock() {
|
||||
Ok(state) => state
|
||||
.as_ref()
|
||||
.map(|auth| auth.user.entitlements().cloud_backup)
|
||||
.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-hour cap on automation requests (0 when automation is unavailable).
|
||||
/// Carried for the future local rate limiter; read by the inert chokepoints.
|
||||
pub async fn requests_per_hour(&self) -> i64 {
|
||||
self
|
||||
.entitlements()
|
||||
.await
|
||||
.map(|e| e.requests_per_hour)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub async fn is_fingerprint_os_allowed(&self, fingerprint_os: Option<&str>) -> bool {
|
||||
let host_os = crate::profile::types::get_host_os();
|
||||
match fingerprint_os {
|
||||
None => true,
|
||||
Some(os) if os == host_os => true,
|
||||
Some(_) => self.has_active_paid_subscription().await,
|
||||
Some(_) => self.can_use_cross_os_fingerprints().await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1016,7 +1150,7 @@ impl CloudAuthManager {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let token = self
|
||||
let result = self
|
||||
.api_call_with_retry(|access_token| {
|
||||
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
|
||||
// Bound the request: without a timeout, an unreachable
|
||||
@@ -1050,7 +1184,31 @@ impl CloudAuthManager {
|
||||
Ok(result.token)
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
.await;
|
||||
|
||||
let token = match result {
|
||||
Ok(token) => token,
|
||||
Err(e) => {
|
||||
// The backend returns 403 (ForbiddenException) for paid-feature blocks:
|
||||
// token-reuse throttle, "active subscription required", and the
|
||||
// primary-device restriction (see donutbrowser-infra wayfern.service.ts).
|
||||
// This is distinct from a 401 (dead access token) — the session is still
|
||||
// valid, the user is just temporarily/conditionally not entitled. So we
|
||||
// do NOT invalidate the session. Instead: drop the stale wayfern token so
|
||||
// no browser launches half-authenticated, re-fetch the profile so the
|
||||
// cached plan reflects the backend's real state (it may have changed),
|
||||
// and signal the UI so the user learns why automation stopped working.
|
||||
if e.contains("(403") || e.contains("Forbidden") {
|
||||
log::warn!("Wayfern token blocked by backend (403): {e}");
|
||||
self.clear_wayfern_token().await;
|
||||
if let Err(fetch_err) = self.fetch_profile().await {
|
||||
log::warn!("Profile re-fetch after wayfern block failed: {fetch_err}");
|
||||
}
|
||||
let _ = crate::events::emit_empty("wayfern-paid-blocked");
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let mut wt = self.wayfern_token.lock().await;
|
||||
*wt = Some(token);
|
||||
@@ -1184,7 +1342,7 @@ pub async fn cloud_exchange_device_code(
|
||||
app_handle: tauri::AppHandle,
|
||||
code: String,
|
||||
) -> Result<CloudAuthState, String> {
|
||||
let state = CLOUD_AUTH.exchange_device_code(&code).await?;
|
||||
let mut state = CLOUD_AUTH.exchange_device_code(&code).await?;
|
||||
|
||||
let has_subscription = CLOUD_AUTH.has_active_paid_subscription().await;
|
||||
log::info!(
|
||||
@@ -1219,17 +1377,25 @@ pub async fn cloud_exchange_device_code(
|
||||
let _ = crate::events::emit_empty("cloud-auth-changed");
|
||||
|
||||
let _ = &app_handle;
|
||||
state.user.entitlements = Some(state.user.entitlements());
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_get_user() -> Result<Option<CloudAuthState>, String> {
|
||||
Ok(CLOUD_AUTH.get_user().await)
|
||||
Ok(CLOUD_AUTH.get_user().await.map(|mut state| {
|
||||
// Always hand the frontend a resolved entitlements object so it never has to
|
||||
// derive capabilities itself (covers older cached state with no entitlements).
|
||||
state.user.entitlements = Some(state.user.entitlements());
|
||||
state
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
|
||||
CLOUD_AUTH.fetch_profile().await
|
||||
let mut user = CLOUD_AUTH.fetch_profile().await?;
|
||||
user.entitlements = Some(user.entitlements());
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
+135
-32
@@ -152,11 +152,11 @@ impl McpServer {
|
||||
self.is_running.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
async fn require_paid_subscription(feature: &str) -> Result<(), McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
// Log the failed gate so customer logs explain why an MCP tool returned
|
||||
// an error. Include enough state (logged-in vs not, plan, status) for
|
||||
// support to diagnose without leaking secrets.
|
||||
/// Gate an MCP tool on a capability the caller already resolved (e.g.
|
||||
/// `CLOUD_AUTH.can_use_browser_automation().await`). Logs the rejected gate
|
||||
/// with enough state for support to diagnose, without leaking secrets.
|
||||
async fn require_capability(feature: &str, allowed: bool) -> Result<(), McpError> {
|
||||
if !allowed {
|
||||
let summary = match CLOUD_AUTH.get_user().await {
|
||||
Some(state) => format!(
|
||||
"logged_in=true plan={} status={} period={:?}",
|
||||
@@ -164,10 +164,10 @@ impl McpServer {
|
||||
),
|
||||
None => "logged_in=false".to_string(),
|
||||
};
|
||||
log::warn!("[mcp] Rejected '{feature}' — paid subscription gate failed ({summary})");
|
||||
log::warn!("[mcp] Rejected '{feature}' — plan does not include it ({summary})");
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: format!("{feature} requires an active paid subscription"),
|
||||
message: format!("{feature} requires a plan that includes this feature"),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
@@ -286,6 +286,9 @@ impl McpServer {
|
||||
.delete(Self::handle_mcp_delete),
|
||||
)
|
||||
.route("/health", get(Self::handle_health))
|
||||
// Inert chokepoint (innermost → runs after auth) for the future per-hour
|
||||
// automation request limit. See rate_limit_middleware.
|
||||
.layer(middleware::from_fn(Self::rate_limit_middleware))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
Self::auth_middleware,
|
||||
@@ -316,6 +319,17 @@ impl McpServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Chokepoint for the future per-hour automation request limit, mirroring the
|
||||
/// REST API's. The limit (`requests_per_hour`, default 100) is plumbed through
|
||||
/// entitlements; this is intentionally inert today — it resolves the limit but
|
||||
/// never blocks. To enforce, count authenticated tool calls per rolling hour
|
||||
/// and return StatusCode::TOO_MANY_REQUESTS once the limit (when > 0) is hit.
|
||||
async fn rate_limit_middleware(req: Request<Body>, next: Next) -> Result<Response, StatusCode> {
|
||||
let _requests_per_hour = CLOUD_AUTH.requests_per_hour().await;
|
||||
// TODO(rate-limit): enforce `_requests_per_hour` for MCP tool calls.
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
async fn auth_middleware(
|
||||
State(state): State<McpHttpState>,
|
||||
req: Request<Body>,
|
||||
@@ -339,8 +353,16 @@ impl McpServer {
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|h| h.strip_prefix("Bearer "));
|
||||
|
||||
let valid =
|
||||
path_token == Some(state.token.as_str()) || header_token == Some(state.token.as_str());
|
||||
// Constant-time comparison to avoid leaking the token prefix via timing.
|
||||
use subtle::ConstantTimeEq;
|
||||
let expected = state.token.as_bytes();
|
||||
let ct_eq = |t: Option<&str>| {
|
||||
t.is_some_and(|t| {
|
||||
let b = t.as_bytes();
|
||||
b.len() == expected.len() && b.ct_eq(expected).into()
|
||||
})
|
||||
};
|
||||
let valid = ct_eq(path_token) || ct_eq(header_token);
|
||||
|
||||
if !valid {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
@@ -508,7 +530,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 +552,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": {
|
||||
@@ -1639,10 +1661,21 @@ impl McpServer {
|
||||
"list_profiles" => self.handle_list_profiles().await,
|
||||
"get_profile" => self.handle_get_profile(arguments).await,
|
||||
"run_profile" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_run_profile(arguments).await
|
||||
}
|
||||
"kill_profile" => self.handle_kill_profile(arguments).await,
|
||||
"kill_profile" => {
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_kill_profile(arguments).await
|
||||
}
|
||||
"create_profile" => self.handle_create_profile(arguments).await,
|
||||
"update_profile" => self.handle_update_profile(arguments).await,
|
||||
"delete_profile" => self.handle_delete_profile(arguments).await,
|
||||
@@ -1671,13 +1704,16 @@ impl McpServer {
|
||||
"connect_vpn" => self.handle_connect_vpn(arguments).await,
|
||||
"disconnect_vpn" => self.handle_disconnect_vpn(arguments).await,
|
||||
"get_vpn_status" => self.handle_get_vpn_status(arguments).await,
|
||||
// Fingerprint management — viewing and editing both require a paid plan.
|
||||
"get_profile_fingerprint" => {
|
||||
Self::require_paid_subscription("Fingerprint").await?;
|
||||
self.handle_get_profile_fingerprint(arguments).await
|
||||
}
|
||||
// Fingerprint management — viewing is free everywhere (matches the REST
|
||||
// API and the get_profile tool, which already expose the config); only
|
||||
// editing requires a paid plan.
|
||||
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(arguments).await,
|
||||
"update_profile_fingerprint" => {
|
||||
Self::require_paid_subscription("Fingerprint").await?;
|
||||
Self::require_capability(
|
||||
"Fingerprint editing",
|
||||
CLOUD_AUTH.can_use_cross_os_fingerprints().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_update_profile_fingerprint(arguments).await
|
||||
}
|
||||
"update_profile_proxy_bypass_rules" => {
|
||||
@@ -1706,7 +1742,11 @@ impl McpServer {
|
||||
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
|
||||
// Synchronizer tools
|
||||
"start_sync_session" => {
|
||||
Self::require_paid_subscription("Synchronizer").await?;
|
||||
Self::require_capability(
|
||||
"Synchronizer",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_start_sync_session(arguments).await
|
||||
}
|
||||
"stop_sync_session" => self.handle_stop_sync_session(arguments).await,
|
||||
@@ -1714,43 +1754,83 @@ impl McpServer {
|
||||
"remove_sync_follower" => self.handle_remove_sync_follower(arguments).await,
|
||||
// Browser interaction tools (require paid subscription)
|
||||
"navigate" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_navigate(arguments).await
|
||||
}
|
||||
"screenshot" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_screenshot(arguments).await
|
||||
}
|
||||
"evaluate_javascript" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_evaluate_javascript(arguments).await
|
||||
}
|
||||
"click_element" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_click_element(arguments).await
|
||||
}
|
||||
"type_text" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_type_text(arguments).await
|
||||
}
|
||||
"get_page_content" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_get_page_content(arguments).await
|
||||
}
|
||||
"get_page_info" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_get_page_info(arguments).await
|
||||
}
|
||||
"get_interactive_elements" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_get_interactive_elements(arguments).await
|
||||
}
|
||||
"click_by_index" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_click_by_index(arguments).await
|
||||
}
|
||||
"type_by_index" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_type_by_index(arguments).await
|
||||
}
|
||||
_ => Err(McpError {
|
||||
@@ -1829,6 +1909,13 @@ impl McpServer {
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
// Launching profiles programmatically requires the automation capability.
|
||||
Self::require_capability(
|
||||
"Launching a profile",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -1910,6 +1997,13 @@ impl McpServer {
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
// Stopping profiles programmatically requires the automation capability.
|
||||
Self::require_capability(
|
||||
"Killing a profile",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -2586,6 +2680,15 @@ impl McpServer {
|
||||
message: "Missing proxy_type".to_string(),
|
||||
})?;
|
||||
|
||||
// The tool schema declares an enum, but JSON-Schema enums are advisory only;
|
||||
// enforce it here so a bad value can't produce a non-functional proxy.
|
||||
if !matches!(proxy_type, "http" | "https" | "socks4" | "socks5") {
|
||||
return Err(McpError {
|
||||
code: -32602,
|
||||
message: "proxy_type must be one of: http, https, socks4, socks5".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let host = arguments
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -3237,10 +3340,10 @@ impl McpServer {
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
if !CLOUD_AUTH.can_use_cross_os_fingerprints().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Fingerprint editing requires an active Pro subscription".to_string(),
|
||||
message: "Fingerprint editing requires a plan that includes it".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,42 @@ use crate::profile::BrowserProfile;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// True if a process command line refers to `profile_path` as a real browser
|
||||
/// profile/data-dir argument, NOT merely a substring. A bare `contains` match
|
||||
/// force-killed unrelated processes that happened to mention the path (editors,
|
||||
/// `tail`, a terminal that `cd`'d there, or another profile whose path has this
|
||||
/// one as a prefix). Mirrors the precise matching in browser_runner/wayfern_manager.
|
||||
///
|
||||
/// Only the macOS and Linux process-kill paths use this; Windows has no
|
||||
/// `find_processes_by_profile_path`, so gate it to avoid a dead-code error there.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn cmd_matches_profile_path(cmd: &[std::ffi::OsString], profile_path: &str) -> bool {
|
||||
let args: Vec<&str> = cmd.iter().filter_map(|a| a.to_str()).collect();
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
// Exact argument equality (Firefox/Camoufox: `-profile <path>`; some launchers
|
||||
// pass the path as its own arg).
|
||||
if *arg == profile_path {
|
||||
return true;
|
||||
}
|
||||
// `--user-data-dir=<path>` (Chromium/Wayfern) or `-profile=<path>`.
|
||||
if let Some(val) = arg
|
||||
.strip_prefix("--user-data-dir=")
|
||||
.or_else(|| arg.strip_prefix("-profile="))
|
||||
{
|
||||
if val == profile_path {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Flag followed by the path as the next argument.
|
||||
if (*arg == "-profile" || *arg == "--user-data-dir")
|
||||
&& args.get(i + 1).is_some_and(|next| *next == profile_path)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// Platform-specific modules
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(dead_code)]
|
||||
@@ -215,16 +251,7 @@ pub mod macos {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if any command line argument contains the profile path
|
||||
let has_profile = cmd.iter().any(|arg| {
|
||||
if let Some(arg_str) = arg.to_str() {
|
||||
arg_str.contains(profile_path)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if has_profile {
|
||||
if cmd_matches_profile_path(cmd, profile_path) {
|
||||
pids.push(pid.as_u32());
|
||||
}
|
||||
}
|
||||
@@ -832,15 +859,7 @@ pub mod linux {
|
||||
continue;
|
||||
}
|
||||
|
||||
let has_profile = cmd.iter().any(|arg| {
|
||||
if let Some(arg_str) = arg.to_str() {
|
||||
arg_str.contains(profile_path)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if has_profile {
|
||||
if cmd_matches_profile_path(cmd, profile_path) {
|
||||
pids.push(pid.as_u32());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1035,7 +1035,7 @@ impl ProfileManager {
|
||||
fs::create_dir_all(&dest_dir)?;
|
||||
}
|
||||
|
||||
let new_profile = BrowserProfile {
|
||||
let mut new_profile = BrowserProfile {
|
||||
id: new_id,
|
||||
name: clone_name,
|
||||
browser: source.browser,
|
||||
@@ -1071,6 +1071,21 @@ impl ProfileManager {
|
||||
updated_at: Some(crate::proxy_manager::now_secs()),
|
||||
};
|
||||
|
||||
// Donut: a clone must NOT be linkable to its source. The source
|
||||
// wayfern_config embeds the persisted fingerprint JSON (including the
|
||||
// canvas_noise_seed), so copying it verbatim makes the clone emit
|
||||
// BYTE-IDENTICAL canvas/WebGL/audio readback hashes and identical device
|
||||
// signals as the source — trivially linkable if both run concurrently. Clear
|
||||
// the fingerprint so the launch path mints a fresh one (a new
|
||||
// canvas_noise_seed via RandBytes + an independent device fingerprint),
|
||||
// exactly as create_profile does when fingerprint.is_none(). NOTE: the
|
||||
// user-data-dir copy above still duplicates cookies/localStorage/TLS state —
|
||||
// a separate storage-linkage vector the user must clear if they want full
|
||||
// isolation between a clone and its source.
|
||||
if let Some(cfg) = new_profile.wayfern_config.as_mut() {
|
||||
cfg.fingerprint = None;
|
||||
}
|
||||
|
||||
self.save_profile(&new_profile)?;
|
||||
|
||||
if let Err(e) = events::emit_empty("profiles-changed") {
|
||||
@@ -2501,7 +2516,7 @@ pub async fn update_camoufox_config(
|
||||
) -> Result<(), String> {
|
||||
if config.fingerprint.is_some()
|
||||
&& !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.can_use_cross_os_fingerprints()
|
||||
.await
|
||||
{
|
||||
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
|
||||
@@ -2529,7 +2544,7 @@ pub async fn update_wayfern_config(
|
||||
) -> Result<(), String> {
|
||||
if config.fingerprint.is_some()
|
||||
&& !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.can_use_cross_os_fingerprints()
|
||||
.await
|
||||
{
|
||||
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
|
||||
|
||||
@@ -2,7 +2,7 @@ use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fs::{self, create_dir_all};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::camoufox_manager::CamoufoxConfig;
|
||||
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
||||
@@ -21,11 +21,11 @@ pub struct DetectedProfile {
|
||||
}
|
||||
|
||||
fn map_browser_type(browser: &str) -> &str {
|
||||
// Firefox-based sources map to the now-deprecated Camoufox. They are no longer
|
||||
// detected for import; the mapping is kept only so the import command can
|
||||
// recognize and REJECT them. Everything else maps to Wayfern.
|
||||
match browser {
|
||||
"firefox" | "firefox-developer" | "zen" => "camoufox",
|
||||
"chromium" | "brave" => "wayfern",
|
||||
"camoufox" => "camoufox",
|
||||
"wayfern" => "wayfern",
|
||||
"firefox" | "firefox-developer" | "zen" | "camoufox" => "camoufox",
|
||||
_ => "wayfern",
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ pub struct ProfileImporter {
|
||||
base_dirs: BaseDirs,
|
||||
downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
|
||||
profile_manager: &'static ProfileManager,
|
||||
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
|
||||
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
|
||||
}
|
||||
|
||||
@@ -44,7 +43,6 @@ impl ProfileImporter {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
|
||||
profile_manager: ProfileManager::instance(),
|
||||
camoufox_manager: crate::camoufox_manager::CamoufoxManager::instance(),
|
||||
wayfern_manager: crate::wayfern_manager::WayfernManager::instance(),
|
||||
}
|
||||
}
|
||||
@@ -58,12 +56,12 @@ impl ProfileImporter {
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut detected_profiles = Vec::new();
|
||||
|
||||
detected_profiles.extend(self.detect_firefox_profiles()?);
|
||||
// Firefox-based browsers (Firefox, Firefox Developer, Zen) map to Camoufox,
|
||||
// which is deprecated — they can no longer be imported. Only Chromium-based
|
||||
// sources (mapping to Wayfern) are detected.
|
||||
detected_profiles.extend(self.detect_chrome_profiles()?);
|
||||
detected_profiles.extend(self.detect_brave_profiles()?);
|
||||
detected_profiles.extend(self.detect_firefox_developer_profiles()?);
|
||||
detected_profiles.extend(self.detect_chromium_profiles()?);
|
||||
detected_profiles.extend(self.detect_zen_browser_profiles()?);
|
||||
|
||||
let mut seen_paths = HashSet::new();
|
||||
let unique_profiles: Vec<DetectedProfile> = detected_profiles
|
||||
@@ -74,80 +72,6 @@ impl ProfileImporter {
|
||||
Ok(unique_profiles)
|
||||
}
|
||||
|
||||
fn detect_firefox_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let firefox_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/Firefox/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
let firefox_dir = app_data.join("Mozilla/Firefox/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
|
||||
|
||||
let local_app_data = self.base_dirs.data_local_dir();
|
||||
let firefox_local_dir = local_app_data.join("Mozilla/Firefox/Profiles");
|
||||
if firefox_local_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_local_dir, "firefox")?);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let firefox_dir = self.base_dirs.home_dir().join(".mozilla/firefox");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn detect_firefox_developer_profiles(
|
||||
&self,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let firefox_dev_alt_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/Firefox Developer Edition/Profiles");
|
||||
|
||||
if firefox_dev_alt_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_alt_dir, "firefox-developer")?);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
let firefox_dev_dir = app_data.join("Mozilla/Firefox Developer Edition/Profiles");
|
||||
if firefox_dev_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let firefox_dev_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join(".mozilla/firefox-dev-edition");
|
||||
if firefox_dev_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn detect_chrome_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
@@ -235,191 +159,6 @@ impl ProfileImporter {
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn detect_zen_browser_profiles(
|
||||
&self,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let zen_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/Zen/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
let zen_dir = app_data.join("Zen/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let zen_dir = self.base_dirs.home_dir().join(".zen");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn scan_firefox_profiles_dir(
|
||||
&self,
|
||||
profiles_dir: &Path,
|
||||
browser_type: &str,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
if !profiles_dir.exists() {
|
||||
return Ok(profiles);
|
||||
}
|
||||
|
||||
let profiles_ini = profiles_dir
|
||||
.parent()
|
||||
.unwrap_or(profiles_dir)
|
||||
.join("profiles.ini");
|
||||
if profiles_ini.exists() {
|
||||
if let Ok(content) = fs::read_to_string(&profiles_ini) {
|
||||
profiles.extend(self.parse_firefox_profiles_ini(&content, profiles_dir, browser_type)?);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(entries) = fs::read_dir(profiles_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let prefs_file = path.join("prefs.js");
|
||||
if prefs_file.exists() {
|
||||
let profile_name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("Unknown Profile");
|
||||
|
||||
let already_added = profiles.iter().any(|p| p.path == path.to_string_lossy());
|
||||
if !already_added {
|
||||
profiles.push(DetectedProfile {
|
||||
browser: browser_type.to_string(),
|
||||
mapped_browser: map_browser_type(browser_type).to_string(),
|
||||
name: format!(
|
||||
"{} Profile - {}",
|
||||
self.get_browser_display_name(browser_type),
|
||||
profile_name
|
||||
),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
description: format!("Profile folder: {profile_name}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn parse_firefox_profiles_ini(
|
||||
&self,
|
||||
content: &str,
|
||||
profiles_dir: &Path,
|
||||
browser_type: &str,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
let mut current_section = String::new();
|
||||
let mut profile_name = String::new();
|
||||
let mut profile_path = String::new();
|
||||
let mut is_relative = true;
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
if line.starts_with('[') && line.ends_with(']') {
|
||||
if !current_section.is_empty()
|
||||
&& current_section.starts_with("Profile")
|
||||
&& !profile_path.is_empty()
|
||||
{
|
||||
let full_path = if is_relative {
|
||||
profiles_dir.join(&profile_path)
|
||||
} else {
|
||||
PathBuf::from(&profile_path)
|
||||
};
|
||||
|
||||
if full_path.exists() {
|
||||
let display_name = if profile_name.is_empty() {
|
||||
format!("{} Profile", self.get_browser_display_name(browser_type))
|
||||
} else {
|
||||
format!(
|
||||
"{} - {}",
|
||||
self.get_browser_display_name(browser_type),
|
||||
profile_name
|
||||
)
|
||||
};
|
||||
|
||||
profiles.push(DetectedProfile {
|
||||
browser: browser_type.to_string(),
|
||||
mapped_browser: map_browser_type(browser_type).to_string(),
|
||||
name: display_name,
|
||||
path: full_path.to_string_lossy().to_string(),
|
||||
description: format!("Profile: {profile_name}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
current_section = line[1..line.len() - 1].to_string();
|
||||
profile_name.clear();
|
||||
profile_path.clear();
|
||||
is_relative = true;
|
||||
} else if line.contains('=') {
|
||||
let parts: Vec<&str> = line.splitn(2, '=').collect();
|
||||
if parts.len() == 2 {
|
||||
let key = parts[0].trim();
|
||||
let value = parts[1].trim();
|
||||
|
||||
match key {
|
||||
"Name" => profile_name = value.to_string(),
|
||||
"Path" => profile_path = value.to_string(),
|
||||
"IsRelative" => is_relative = value == "1",
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !current_section.is_empty()
|
||||
&& current_section.starts_with("Profile")
|
||||
&& !profile_path.is_empty()
|
||||
{
|
||||
let full_path = if is_relative {
|
||||
profiles_dir.join(&profile_path)
|
||||
} else {
|
||||
PathBuf::from(&profile_path)
|
||||
};
|
||||
|
||||
if full_path.exists() {
|
||||
let display_name = if profile_name.is_empty() {
|
||||
format!("{} Profile", self.get_browser_display_name(browser_type))
|
||||
} else {
|
||||
format!(
|
||||
"{} - {}",
|
||||
self.get_browser_display_name(browser_type),
|
||||
profile_name
|
||||
)
|
||||
};
|
||||
|
||||
profiles.push(DetectedProfile {
|
||||
browser: browser_type.to_string(),
|
||||
mapped_browser: map_browser_type(browser_type).to_string(),
|
||||
name: display_name,
|
||||
path: full_path.to_string_lossy().to_string(),
|
||||
description: format!("Profile: {profile_name}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn scan_chrome_profiles_dir(
|
||||
&self,
|
||||
browser_dir: &Path,
|
||||
@@ -493,7 +232,7 @@ impl ProfileImporter {
|
||||
browser_type: &str,
|
||||
new_profile_name: &str,
|
||||
proxy_id: Option<String>,
|
||||
camoufox_config: Option<CamoufoxConfig>,
|
||||
_camoufox_config: Option<CamoufoxConfig>,
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let source_path = Path::new(source_path);
|
||||
@@ -529,88 +268,9 @@ impl ProfileImporter {
|
||||
|
||||
let version = self.get_default_version_for_browser(mapped)?;
|
||||
|
||||
let final_camoufox_config = if mapped == "camoufox" {
|
||||
let mut config = camoufox_config.unwrap_or_default();
|
||||
|
||||
if let Some(ref proxy_id_val) = proxy_id {
|
||||
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_val) {
|
||||
let proxy_url = if let (Some(username), Some(password)) =
|
||||
(&proxy_settings.username, &proxy_settings.password)
|
||||
{
|
||||
format!(
|
||||
"{}://{}:{}@{}:{}",
|
||||
proxy_settings.proxy_type.to_lowercase(),
|
||||
username,
|
||||
password,
|
||||
proxy_settings.host,
|
||||
proxy_settings.port
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}://{}:{}",
|
||||
proxy_settings.proxy_type.to_lowercase(),
|
||||
proxy_settings.host,
|
||||
proxy_settings.port
|
||||
)
|
||||
};
|
||||
config.proxy = Some(proxy_url);
|
||||
}
|
||||
}
|
||||
|
||||
if config.fingerprint.is_none() {
|
||||
let temp_profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: new_profile_name.to_string(),
|
||||
browser: mapped.to_string(),
|
||||
version: version.clone(),
|
||||
proxy_id: proxy_id.clone(),
|
||||
vpn_id: None,
|
||||
launch_hook: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
wayfern_config: None,
|
||||
group_id: None,
|
||||
tags: Vec::new(),
|
||||
note: None,
|
||||
sync_mode: SyncMode::Disabled,
|
||||
encryption_salt: None,
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral: false,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
match self
|
||||
.camoufox_manager
|
||||
.generate_fingerprint_config(app_handle, &temp_profile, &config)
|
||||
.await
|
||||
{
|
||||
Ok(fp) => config.fingerprint = Some(fp),
|
||||
Err(e) => {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to generate fingerprint for imported profile '{new_profile_name}': {e}"
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config.proxy = None;
|
||||
Some(config)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Camoufox import is removed; only Wayfern profiles are imported now, so the
|
||||
// imported profile never carries a Camoufox config.
|
||||
let final_camoufox_config: Option<CamoufoxConfig> = None;
|
||||
|
||||
let final_wayfern_config = if mapped == "wayfern" {
|
||||
let mut config = wayfern_config.unwrap_or_default();
|
||||
@@ -806,6 +466,12 @@ pub async fn import_browser_profile(
|
||||
camoufox_config: Option<CamoufoxConfig>,
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
) -> Result<(), String> {
|
||||
// Camoufox is deprecated — Firefox-based profiles (which map to Camoufox) can
|
||||
// no longer be imported. Reject them before doing any work.
|
||||
if map_browser_type(&browser_type) == "camoufox" {
|
||||
return Err(serde_json::json!({ "code": "CAMOUFOX_IMPORT_DEPRECATED" }).to_string());
|
||||
}
|
||||
|
||||
let fingerprint_os = camoufox_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.os.as_deref())
|
||||
@@ -897,24 +563,6 @@ mod tests {
|
||||
let _profiles = result.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_firefox_profiles_dir_nonexistent() {
|
||||
let (importer, temp_dir) = create_test_profile_importer();
|
||||
|
||||
let nonexistent_dir = temp_dir.path().join("nonexistent");
|
||||
let result = importer.scan_firefox_profiles_dir(&nonexistent_dir, "firefox");
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Should handle nonexistent directory gracefully"
|
||||
);
|
||||
let profiles = result.unwrap();
|
||||
assert!(
|
||||
profiles.is_empty(),
|
||||
"Should return empty vector for nonexistent directory"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_chrome_profiles_dir_nonexistent() {
|
||||
let (importer, temp_dir) = create_test_profile_importer();
|
||||
@@ -933,51 +581,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_firefox_profiles_ini_empty() {
|
||||
let (importer, _temp_dir) = create_test_profile_importer();
|
||||
|
||||
let empty_content = "";
|
||||
let profiles_dir = Path::new("/tmp");
|
||||
let result = importer.parse_firefox_profiles_ini(empty_content, profiles_dir, "firefox");
|
||||
|
||||
assert!(result.is_ok(), "Should handle empty profiles.ini");
|
||||
let profiles = result.unwrap();
|
||||
assert!(
|
||||
profiles.is_empty(),
|
||||
"Should return empty vector for empty content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_firefox_profiles_ini_valid() {
|
||||
let (importer, temp_dir) = create_test_profile_importer();
|
||||
|
||||
let profiles_dir = temp_dir.path().join("profiles");
|
||||
let profile_dir = profiles_dir.join("test.profile");
|
||||
fs::create_dir_all(&profile_dir).expect("Should create profile directory");
|
||||
|
||||
let prefs_file = profile_dir.join("prefs.js");
|
||||
fs::write(&prefs_file, "// Firefox preferences").expect("Should create prefs.js");
|
||||
|
||||
let profiles_ini_content = r#"
|
||||
[Profile0]
|
||||
Name=Test Profile
|
||||
IsRelative=1
|
||||
Path=test.profile
|
||||
"#;
|
||||
|
||||
let result =
|
||||
importer.parse_firefox_profiles_ini(profiles_ini_content, &profiles_dir, "firefox");
|
||||
|
||||
assert!(result.is_ok(), "Should parse valid profiles.ini");
|
||||
let profiles = result.unwrap();
|
||||
assert_eq!(profiles.len(), 1, "Should find one profile");
|
||||
assert_eq!(profiles[0].name, "Firefox - Test Profile");
|
||||
assert_eq!(profiles[0].browser, "firefox");
|
||||
assert_eq!(profiles[0].mapped_browser, "camoufox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copy_directory_recursive() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
|
||||
@@ -774,6 +774,17 @@ impl ProxyManager {
|
||||
list
|
||||
}
|
||||
|
||||
/// Insert/replace a stored proxy in the in-memory map. Used by sync's
|
||||
/// download_proxy after it writes the file to disk, mirroring how
|
||||
/// download_group/download_vpn/download_extension keep their managers'
|
||||
/// in-memory state in sync. Without this, get_stored_proxies (which reads
|
||||
/// only the map) never sees a downloaded proxy until restart, so sync keeps
|
||||
/// re-downloading it indefinitely.
|
||||
pub fn upsert_stored_proxy(&self, proxy: StoredProxy) {
|
||||
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
stored_proxies.insert(proxy.id.clone(), proxy);
|
||||
}
|
||||
|
||||
// Get a stored proxy by ID
|
||||
|
||||
// Update a stored proxy
|
||||
@@ -1730,12 +1741,18 @@ impl ProxyManager {
|
||||
.arg("--id")
|
||||
.arg(&proxy_id);
|
||||
|
||||
let output = proxy_cmd.output().await.unwrap();
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::warn!("Proxy stop error: {stderr}");
|
||||
// We still return Ok since we've already removed the proxy from our tracking
|
||||
// A failed spawn (sidecar missing, permission denied, fd exhaustion) must
|
||||
// not panic the cleanup task — the proxy is already removed from tracking,
|
||||
// so degrade gracefully like the non-success branch below.
|
||||
match proxy_cmd.output().await {
|
||||
Ok(output) if !output.status.success() => {
|
||||
log::warn!(
|
||||
"Proxy stop error: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("Failed to run donut-proxy stop: {e}"),
|
||||
}
|
||||
|
||||
// Clear profile-to-proxy mapping if it references this proxy
|
||||
@@ -1795,11 +1812,16 @@ impl ProxyManager {
|
||||
.arg("--id")
|
||||
.arg(&proxy_id);
|
||||
|
||||
let output = proxy_cmd.output().await.unwrap();
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::warn!("Proxy stop error: {stderr}");
|
||||
// Don't panic if the sidecar can't be spawned — still clear the mapping.
|
||||
match proxy_cmd.output().await {
|
||||
Ok(output) if !output.status.success() => {
|
||||
log::warn!(
|
||||
"Proxy stop error: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("Failed to run donut-proxy stop: {e}"),
|
||||
}
|
||||
|
||||
// Clear profile-to-proxy mapping
|
||||
|
||||
@@ -509,47 +509,20 @@ async fn handle_http_via_socks4(
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve target host to IP (SOCKS4 requires IP addresses)
|
||||
let target_ip = match tokio::net::lookup_host((target_host, target_port)).await {
|
||||
Ok(mut addrs) => {
|
||||
if let Some(addr) = addrs.next() {
|
||||
match addr.ip() {
|
||||
std::net::IpAddr::V4(ipv4) => ipv4.octets(),
|
||||
std::net::IpAddr::V6(_) => {
|
||||
log::error!("SOCKS4 does not support IPv6");
|
||||
let mut response = Response::new(Full::new(Bytes::from(
|
||||
"SOCKS4 does not support IPv6 addresses",
|
||||
)));
|
||||
*response.status_mut() = StatusCode::BAD_GATEWAY;
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::error!("Failed to resolve target host: {}", target_host);
|
||||
let mut response = Response::new(Full::new(Bytes::from(format!(
|
||||
"Failed to resolve target host: {}",
|
||||
target_host
|
||||
))));
|
||||
*response.status_mut() = StatusCode::BAD_GATEWAY;
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to resolve target host {}: {}", target_host, e);
|
||||
let mut response = Response::new(Full::new(Bytes::from(format!(
|
||||
"Failed to resolve target host: {}",
|
||||
e
|
||||
))));
|
||||
*response.status_mut() = StatusCode::BAD_GATEWAY;
|
||||
return Ok(response);
|
||||
}
|
||||
};
|
||||
|
||||
// Build SOCKS4 CONNECT request
|
||||
// Build a SOCKS4a CONNECT request. We deliberately do NOT resolve the target
|
||||
// hostname locally: tokio::net::lookup_host would call the HOST resolver
|
||||
// (getaddrinfo), leaking the destination domain to the host's DNS server and
|
||||
// defeating the per-profile proxy. SOCKS4a has the PROXY resolve the name —
|
||||
// send the sentinel IP 0.0.0.x (x != 0), then the NULL-terminated userid, then
|
||||
// the NULL-terminated hostname. (Most SOCKS4 proxies support 4a; a legacy
|
||||
// SOCKS4-only proxy without remote DNS cannot be used leak-free for plaintext
|
||||
// HTTP — prefer SOCKS5 there.)
|
||||
let mut socks_request = vec![0x04, 0x01]; // SOCKS4, CONNECT
|
||||
socks_request.extend_from_slice(&target_port.to_be_bytes());
|
||||
socks_request.extend_from_slice(&target_ip);
|
||||
socks_request.push(0); // NULL terminator for userid
|
||||
socks_request.extend_from_slice(&[0, 0, 0, 1]); // 0.0.0.1 => SOCKS4a remote-DNS marker
|
||||
socks_request.push(0); // empty userid, NULL-terminated
|
||||
socks_request.extend_from_slice(target_host.as_bytes()); // hostname for the proxy to resolve
|
||||
socks_request.push(0); // NULL-terminated hostname
|
||||
|
||||
// Send SOCKS4 CONNECT request
|
||||
if let Err(e) = socks_stream.write_all(&socks_request).await {
|
||||
@@ -1071,8 +1044,19 @@ fn build_reqwest_client_with_proxy(
|
||||
Proxy::http(upstream_url)?
|
||||
}
|
||||
"socks5" => {
|
||||
// For SOCKS5, reqwest supports it directly
|
||||
Proxy::all(upstream_url)?
|
||||
// Donut: force REMOTE (proxy-side) DNS for plaintext HTTP over a SOCKS5
|
||||
// upstream. reqwest maps the bare `socks5` scheme to DnsResolve::Local,
|
||||
// which resolves the destination hostname on the HOST (getaddrinfo) BEFORE
|
||||
// connecting — leaking the destination domain to the host's DNS resolver
|
||||
// and defeating the per-profile proxy. The `socks5h` scheme maps to
|
||||
// DnsResolve::Proxy, so the proxy resolves the hostname and nothing leaks.
|
||||
// (The CONNECT/HTTPS path already does remote DNS via connect_via_socks's
|
||||
// AddrKind::Domain.)
|
||||
let remote_dns_url = match upstream_url.strip_prefix("socks5://") {
|
||||
Some(rest) => format!("socks5h://{rest}"),
|
||||
None => upstream_url.to_string(),
|
||||
};
|
||||
Proxy::all(remote_dns_url)?
|
||||
}
|
||||
"socks4" => {
|
||||
// SOCKS4 is handled manually in handle_http_via_socks4
|
||||
|
||||
@@ -294,7 +294,10 @@ impl SyncProgressTracker {
|
||||
|
||||
/// Check if sync is configured (cloud or self-hosted)
|
||||
pub fn is_sync_configured() -> bool {
|
||||
if crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync() {
|
||||
// Cloud backup is a plan capability. Every paid plan (incl. the future
|
||||
// "starter" tier) grants it, but gating on the capability — not just "is paid"
|
||||
// — keeps this correct if a plan without cloud backup is ever added.
|
||||
if crate::cloud_auth::CLOUD_AUTH.can_use_cloud_backup_sync() {
|
||||
return true;
|
||||
}
|
||||
let manager = SettingsManager::instance();
|
||||
@@ -1597,6 +1600,13 @@ impl SyncEngine {
|
||||
))
|
||||
})?;
|
||||
|
||||
// Keep the in-memory cache in sync with disk. Without this, get_stored_proxies
|
||||
// (which reads only the in-memory map) never sees the downloaded proxy until
|
||||
// restart, so check_for_missing_synced_entities/sync_proxy treat it as
|
||||
// missing every pass and re-download it forever. Mirrors download_group/
|
||||
// download_vpn/download_extension.
|
||||
proxy_manager.upsert_stored_proxy(proxy.clone());
|
||||
|
||||
// Emit event for UI update
|
||||
if let Some(_handle) = app_handle {
|
||||
let _ = events::emit("stored-proxies-changed", ());
|
||||
|
||||
@@ -138,6 +138,46 @@ impl WayfernManager {
|
||||
fingerprint
|
||||
}
|
||||
|
||||
/// Derive the on-screen window size Chromium should open at, from the stored
|
||||
/// fingerprint. `Wayfern.setFingerprint` only spoofs what the page *reports*
|
||||
/// for `windowOuterWidth`/`screenWidth`/etc.; it does not move or resize the
|
||||
/// real top-level window. Without `--window-size` the OS window keeps
|
||||
/// Chromium's default, so the visible window contradicts the reported
|
||||
/// dimensions — a detectable mismatch. We pass `--window-size` so the actual
|
||||
/// window matches the fingerprint.
|
||||
///
|
||||
/// Keys are the camelCase fields Wayfern uses in its fingerprint
|
||||
/// (`windowOuterWidth`, `screenAvailWidth`, …) — NOT the dotted
|
||||
/// Camoufox-style keys. Preference order, matching how the fingerprint
|
||||
/// describes the window:
|
||||
/// 1. `windowOuterWidth` / `windowOuterHeight` — the real window size.
|
||||
/// 2. `screenAvailWidth` / `screenAvailHeight` — usable screen area.
|
||||
/// 3. `screenWidth` / `screenHeight` — full screen.
|
||||
///
|
||||
/// Returns `None` when the fingerprint carries no usable dimensions, leaving
|
||||
/// Chromium's default untouched. The fingerprint JSON may be the bare object
|
||||
/// or the legacy `{ "fingerprint": {...} }` wrapper.
|
||||
fn window_size_from_fingerprint(fingerprint_json: &str) -> Option<(u32, u32)> {
|
||||
let parsed: serde_json::Value = serde_json::from_str(fingerprint_json).ok()?;
|
||||
let fp = parsed.get("fingerprint").unwrap_or(&parsed);
|
||||
let obj = fp.as_object()?;
|
||||
|
||||
// Accept both numeric and stringified numbers (Wayfern emits numbers, but a
|
||||
// CDP echo or older saved fingerprint may stringify them).
|
||||
let read = |key: &str| -> Option<u32> {
|
||||
let v = obj.get(key)?;
|
||||
v.as_u64()
|
||||
.or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
|
||||
.filter(|n| *n > 0)
|
||||
.map(|n| n as u32)
|
||||
};
|
||||
let pair = |w: &str, h: &str| -> Option<(u32, u32)> { Some((read(w)?, read(h)?)) };
|
||||
|
||||
pair("windowOuterWidth", "windowOuterHeight")
|
||||
.or_else(|| pair("screenAvailWidth", "screenAvailHeight"))
|
||||
.or_else(|| pair("screenWidth", "screenHeight"))
|
||||
}
|
||||
|
||||
async fn wait_for_cdp_ready(
|
||||
&self,
|
||||
port: u16,
|
||||
@@ -611,13 +651,30 @@ impl WayfernManager {
|
||||
"--disable-session-crashed-bubble".to_string(),
|
||||
"--hide-crash-restore-bubble".to_string(),
|
||||
"--disable-infobars".to_string(),
|
||||
"--disable-features=DialMediaRouteProvider,DnsOverHttps,AsyncDns".to_string(),
|
||||
// Prefetch* / NoStatePrefetch: cross-site Speculation-Rules prefetch uses
|
||||
// an isolated NetworkContext that defaults to DIRECT egress (real host IP
|
||||
// leaks past the per-profile proxy). Disabling via a LAUNCH FLAG cannot be
|
||||
// re-enabled by an imported/synced network_prediction_options pref (which a
|
||||
// compile-time pref default could be).
|
||||
"--disable-features=DialMediaRouteProvider,DnsOverHttps,AsyncDns,Prefetch,PrefetchProxy,SpeculationRulesPrefetchFuture,NoStatePrefetch".to_string(),
|
||||
"--use-mock-keychain".to_string(),
|
||||
"--password-store=basic".to_string(),
|
||||
];
|
||||
|
||||
if headless {
|
||||
args.push("--headless=new".to_string());
|
||||
} else if let Some((w, h)) = config
|
||||
.fingerprint
|
||||
.as_deref()
|
||||
.and_then(Self::window_size_from_fingerprint)
|
||||
{
|
||||
// Size the real OS window to match the fingerprint so the visible window
|
||||
// agrees with the reported windowOuterWidth/screen dimensions. Anchor at
|
||||
// 0,0 so the window also fits within the spoofed screen origin. Skipped in
|
||||
// headless mode, where there is no on-screen window.
|
||||
log::info!("Sizing Wayfern window to fingerprint dimensions: {w}x{h}");
|
||||
args.push(format!("--window-size={w},{h}"));
|
||||
args.push("--window-position=0,0".to_string());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -1198,3 +1255,72 @@ impl WayfernManager {
|
||||
lazy_static::lazy_static! {
|
||||
static ref WAYFERN_MANAGER: WayfernManager = WayfernManager::new();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn window_size_prefers_outer_window_dimensions() {
|
||||
// Field names + values mirror a real Wayfern fingerprint (camelCase).
|
||||
let fp = r#"{"windowOuterWidth": 1268, "windowOuterHeight": 764,
|
||||
"windowInnerWidth": 1253, "windowInnerHeight": 630,
|
||||
"screenAvailWidth": 1280, "screenAvailHeight": 775,
|
||||
"screenWidth": 1280, "screenHeight": 800}"#;
|
||||
assert_eq!(
|
||||
WayfernManager::window_size_from_fingerprint(fp),
|
||||
Some((1268, 764))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_size_falls_back_to_avail_then_full_screen() {
|
||||
let avail = r#"{"screenAvailWidth": 1280, "screenAvailHeight": 775,
|
||||
"screenWidth": 1280, "screenHeight": 800}"#;
|
||||
assert_eq!(
|
||||
WayfernManager::window_size_from_fingerprint(avail),
|
||||
Some((1280, 775))
|
||||
);
|
||||
|
||||
let full = r#"{"screenWidth": 2560, "screenHeight": 1440}"#;
|
||||
assert_eq!(
|
||||
WayfernManager::window_size_from_fingerprint(full),
|
||||
Some((2560, 1440))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_size_handles_wrapper_and_stringified_numbers() {
|
||||
let wrapped = r#"{"fingerprint": {"windowOuterWidth": "1366", "windowOuterHeight": "768"}}"#;
|
||||
assert_eq!(
|
||||
WayfernManager::window_size_from_fingerprint(wrapped),
|
||||
Some((1366, 768))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_size_none_when_missing_or_invalid() {
|
||||
// No dimensions at all.
|
||||
assert_eq!(
|
||||
WayfernManager::window_size_from_fingerprint(r#"{"userAgent": "x"}"#),
|
||||
None
|
||||
);
|
||||
// A width with no matching height is not a usable pair.
|
||||
assert_eq!(
|
||||
WayfernManager::window_size_from_fingerprint(r#"{"windowOuterWidth": 1268}"#),
|
||||
None
|
||||
);
|
||||
// Zero is rejected as a degenerate size.
|
||||
assert_eq!(
|
||||
WayfernManager::window_size_from_fingerprint(
|
||||
r#"{"windowOuterWidth": 0, "windowOuterHeight": 0}"#
|
||||
),
|
||||
None
|
||||
);
|
||||
// Not valid JSON.
|
||||
assert_eq!(
|
||||
WayfernManager::window_size_from_fingerprint("not json"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.25.0",
|
||||
"version": "0.26.0",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
+24
-9
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AccountPage } from "@/components/account-page";
|
||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { CamoufoxDeprecationDialog } from "@/components/camoufox-deprecation-dialog";
|
||||
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
||||
import { CloseConfirmDialog } from "@/components/close-confirm-dialog";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
@@ -59,6 +60,7 @@ import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { getEntitlements } from "@/lib/entitlements";
|
||||
import {
|
||||
ONBOARDING_TOUR_FINISHED_EVENT,
|
||||
setOnboardingActive,
|
||||
@@ -225,10 +227,7 @@ export default function Home() {
|
||||
|
||||
// Cloud auth for cross-OS unlock
|
||||
const { user: cloudUser } = useCloudAuth();
|
||||
const crossOsUnlocked =
|
||||
cloudUser?.plan !== "free" &&
|
||||
(cloudUser?.subscriptionStatus === "active" ||
|
||||
cloudUser?.planPeriod === "lifetime");
|
||||
const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints;
|
||||
|
||||
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
|
||||
useState(false);
|
||||
@@ -1168,11 +1167,14 @@ export default function Home() {
|
||||
profileId: profile.id,
|
||||
syncMode: enabling ? "Regular" : "Disabled",
|
||||
});
|
||||
showSuccessToast(enabling ? "Sync enabled" : "Sync disabled", {
|
||||
description: enabling
|
||||
? "Profile sync has been enabled"
|
||||
: "Profile sync has been disabled",
|
||||
});
|
||||
showSuccessToast(
|
||||
t(enabling ? "sync.enabledToast" : "sync.disabledToast"),
|
||||
{
|
||||
description: t(
|
||||
enabling ? "sync.enabledDescription" : "sync.disabledDescription",
|
||||
),
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast(t("errors.updateSyncSettingsFailed"));
|
||||
@@ -1325,6 +1327,7 @@ export default function Home() {
|
||||
let unlistenStarted: (() => void) | undefined;
|
||||
let unlistenProgress: (() => void) | undefined;
|
||||
let unlistenCompleted: (() => void) | undefined;
|
||||
let unlistenWayfernBlocked: (() => void) | undefined;
|
||||
|
||||
void (async () => {
|
||||
unlistenRequired = await listen(
|
||||
@@ -1386,6 +1389,16 @@ export default function Home() {
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
unlistenWayfernBlocked = await listen("wayfern-paid-blocked", () => {
|
||||
showToast({
|
||||
id: "wayfern-paid-blocked",
|
||||
type: "error",
|
||||
title: t("wayfernBlocked.title"),
|
||||
description: t("wayfernBlocked.description"),
|
||||
duration: 15000,
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
@@ -1393,6 +1406,7 @@ export default function Home() {
|
||||
unlistenStarted?.();
|
||||
unlistenProgress?.();
|
||||
unlistenCompleted?.();
|
||||
unlistenWayfernBlocked?.();
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
@@ -1512,6 +1526,7 @@ export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background font-(family-name:--font-geist-sans)">
|
||||
<CloseConfirmDialog />
|
||||
<CamoufoxDeprecationDialog profiles={profiles} />
|
||||
<HomeHeader
|
||||
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
||||
searchQuery={searchQuery}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { getEntitlements } from "@/lib/entitlements";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { SyncSettings } from "@/types";
|
||||
|
||||
@@ -298,7 +299,7 @@ export function AccountPage({
|
||||
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
user.plan !== "free" &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === false && (
|
||||
<p className="text-xs text-warning">
|
||||
{t("account.automationPrimaryOnly")}
|
||||
@@ -306,7 +307,7 @@ export function AccountPage({
|
||||
)}
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
user.plan !== "free" &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === true &&
|
||||
(user.deviceCount ?? 1) > 1 && (
|
||||
<p className="text-xs text-success">
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface CamoufoxDeprecationDialogProps {
|
||||
profiles: BrowserProfile[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Warns users who still have Camoufox profiles that Camoufox support is ending.
|
||||
* Shown once per app session (this component mounts for the app lifetime), only
|
||||
* when at least one Camoufox profile exists. Not a toast — a blocking dialog so
|
||||
* the deprecation can't be missed.
|
||||
*/
|
||||
export function CamoufoxDeprecationDialog({
|
||||
profiles,
|
||||
}: CamoufoxDeprecationDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [shown, setShown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (shown) return;
|
||||
const hasCamoufox = profiles.some((p) => p.browser === "camoufox");
|
||||
if (hasCamoufox) {
|
||||
setIsOpen(true);
|
||||
setShown(true);
|
||||
}
|
||||
}, [profiles, shown]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuTriangleAlert className="size-5 text-warning" />
|
||||
{t("camoufoxDeprecation.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("camoufoxDeprecation.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void openUrl(
|
||||
"https://github.com/zhom/donutbrowser/discussions/426",
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.learnMore")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("camoufoxDeprecation.acknowledge")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,6 @@ import { GoPlus } from "react-icons/go";
|
||||
import { LuCheck, LuChevronsUpDown, LuLoaderCircle } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -56,15 +54,9 @@ import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { getBrowserIcon } from "@/lib/browser-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
BrowserReleaseTypes,
|
||||
CamoufoxConfig,
|
||||
CamoufoxOS,
|
||||
WayfernConfig,
|
||||
WayfernOS,
|
||||
} from "@/types";
|
||||
import type { BrowserReleaseTypes, WayfernConfig, WayfernOS } from "@/types";
|
||||
|
||||
const getCurrentOS = (): CamoufoxOS => {
|
||||
const getCurrentOS = (): WayfernOS => {
|
||||
if (typeof navigator === "undefined") return "linux";
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
if (platform.includes("win")) return "windows";
|
||||
@@ -86,7 +78,6 @@ interface CreateProfileDialogProps {
|
||||
releaseType: string;
|
||||
proxyId?: string;
|
||||
vpnId?: string;
|
||||
camoufoxConfig?: CamoufoxConfig;
|
||||
wayfernConfig?: WayfernConfig;
|
||||
groupId?: string;
|
||||
extensionGroupId?: string;
|
||||
@@ -105,10 +96,6 @@ interface BrowserOption {
|
||||
}
|
||||
|
||||
const browserOptions: BrowserOption[] = [
|
||||
{
|
||||
value: "camoufox",
|
||||
label: "Camoufox",
|
||||
},
|
||||
{
|
||||
value: "wayfern",
|
||||
label: "Wayfern",
|
||||
@@ -126,28 +113,24 @@ export function CreateProfileDialog({
|
||||
const proxyListboxIdAntiDetect = useId();
|
||||
const proxyListboxIdRegular = useId();
|
||||
const [profileName, setProfileName] = useState("");
|
||||
// Camoufox is deprecated: only Wayfern profiles can be created, so the dialog
|
||||
// opens straight into the Wayfern config step (no browser-selection screen).
|
||||
const [currentStep, setCurrentStep] = useState<
|
||||
"browser-selection" | "browser-config"
|
||||
>("browser-selection");
|
||||
>("browser-config");
|
||||
const [activeTab, setActiveTab] = useState("anti-detect");
|
||||
|
||||
// Browser selection states
|
||||
// Browser selection states. Defaults to Wayfern — the only creatable browser.
|
||||
const [selectedBrowser, setSelectedBrowser] =
|
||||
useState<BrowserTypeString | null>(null);
|
||||
useState<BrowserTypeString>("wayfern");
|
||||
const [selectedProxyId, setSelectedProxyId] = useState<string>();
|
||||
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
|
||||
const [dnsBlocklist, setDnsBlocklist] = useState<string>("");
|
||||
const [launchHook, setLaunchHook] = useState("");
|
||||
|
||||
// Camoufox anti-detect states
|
||||
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>(() => ({
|
||||
geoip: true, // Default to automatic geoip
|
||||
os: getCurrentOS(), // Default to current OS
|
||||
}));
|
||||
|
||||
// Wayfern anti-detect states
|
||||
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>(() => ({
|
||||
os: getCurrentOS() as WayfernOS, // Default to current OS
|
||||
os: getCurrentOS(), // Default to current OS
|
||||
}));
|
||||
|
||||
// Handle browser selection from the initial screen
|
||||
@@ -156,22 +139,23 @@ export function CreateProfileDialog({
|
||||
setCurrentStep("browser-config");
|
||||
};
|
||||
|
||||
// Handle back button
|
||||
const handleBack = () => {
|
||||
setCurrentStep("browser-selection");
|
||||
setSelectedBrowser(null);
|
||||
// Reset the form fields without leaving the Wayfern config step — Camoufox is
|
||||
// deprecated, so there is no browser-selection screen to go back to.
|
||||
const resetForm = () => {
|
||||
setSelectedBrowser("wayfern");
|
||||
setProfileName("");
|
||||
setSelectedProxyId(undefined);
|
||||
setLaunchHook("");
|
||||
};
|
||||
|
||||
// Handle back button
|
||||
const handleBack = () => {
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
setCurrentStep("browser-selection");
|
||||
setSelectedBrowser(null);
|
||||
setProfileName("");
|
||||
setSelectedProxyId(undefined);
|
||||
setLaunchHook("");
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
|
||||
@@ -307,16 +291,15 @@ export function CreateProfileDialog({
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadSupportedBrowsers();
|
||||
// Load downloaded versions for both anti-detect browsers up front so the
|
||||
// selection-screen availability gate is accurate before either is picked.
|
||||
// Load downloaded Wayfern versions up front so the availability gate is
|
||||
// accurate. Camoufox is deprecated and no longer creatable.
|
||||
void loadDownloadedVersions("wayfern");
|
||||
void loadDownloadedVersions("camoufox");
|
||||
// Load release types when a browser is selected
|
||||
if (selectedBrowser) {
|
||||
void loadReleaseTypes(selectedBrowser);
|
||||
}
|
||||
// Check and download GeoIP database if needed for Camoufox or Wayfern
|
||||
if (selectedBrowser === "camoufox" || selectedBrowser === "wayfern") {
|
||||
// Wayfern needs the GeoIP database for fingerprint generation.
|
||||
if (selectedBrowser === "wayfern") {
|
||||
void checkAndDownloadGeoIPDatabase();
|
||||
}
|
||||
}
|
||||
@@ -417,66 +400,34 @@ export function CreateProfileDialog({
|
||||
: undefined;
|
||||
try {
|
||||
if (activeTab === "anti-detect") {
|
||||
// Anti-detect browser - check if Wayfern or Camoufox is selected
|
||||
if (selectedBrowser === "wayfern") {
|
||||
const bestWayfernVersion = getCreatableVersion("wayfern");
|
||||
if (!bestWayfernVersion) {
|
||||
console.error("No Wayfern version available");
|
||||
return;
|
||||
}
|
||||
|
||||
// The fingerprint will be generated at launch time by the Rust backend
|
||||
const finalWayfernConfig = { ...wayfernConfig };
|
||||
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: "wayfern" as BrowserTypeString,
|
||||
version: bestWayfernVersion.version,
|
||||
releaseType: bestWayfernVersion.releaseType,
|
||||
proxyId: resolvedProxyId,
|
||||
vpnId: resolvedVpnId,
|
||||
wayfernConfig: finalWayfernConfig,
|
||||
groupId:
|
||||
selectedGroupId && selectedGroupId !== "__all__"
|
||||
? selectedGroupId
|
||||
: undefined,
|
||||
extensionGroupId: selectedExtensionGroupId,
|
||||
ephemeral,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
launchHook: launchHook.trim() || undefined,
|
||||
password: passwordToSet,
|
||||
});
|
||||
} else {
|
||||
// Default to Camoufox
|
||||
const bestCamoufoxVersion = getCreatableVersion("camoufox");
|
||||
if (!bestCamoufoxVersion) {
|
||||
console.error("No Camoufox version available");
|
||||
return;
|
||||
}
|
||||
|
||||
// The fingerprint will be generated at launch time by the Rust backend
|
||||
// We don't need to generate it here during profile creation
|
||||
const finalCamoufoxConfig = { ...camoufoxConfig };
|
||||
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: "camoufox" as BrowserTypeString,
|
||||
version: bestCamoufoxVersion.version,
|
||||
releaseType: bestCamoufoxVersion.releaseType,
|
||||
proxyId: resolvedProxyId,
|
||||
vpnId: resolvedVpnId,
|
||||
camoufoxConfig: finalCamoufoxConfig,
|
||||
groupId:
|
||||
selectedGroupId && selectedGroupId !== "__all__"
|
||||
? selectedGroupId
|
||||
: undefined,
|
||||
extensionGroupId: selectedExtensionGroupId,
|
||||
ephemeral,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
launchHook: launchHook.trim() || undefined,
|
||||
password: passwordToSet,
|
||||
});
|
||||
// Camoufox is deprecated — only Wayfern anti-detect profiles are created.
|
||||
const bestWayfernVersion = getCreatableVersion("wayfern");
|
||||
if (!bestWayfernVersion) {
|
||||
console.error("No Wayfern version available");
|
||||
return;
|
||||
}
|
||||
|
||||
// The fingerprint will be generated at launch time by the Rust backend
|
||||
const finalWayfernConfig = { ...wayfernConfig };
|
||||
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: "wayfern" as BrowserTypeString,
|
||||
version: bestWayfernVersion.version,
|
||||
releaseType: bestWayfernVersion.releaseType,
|
||||
proxyId: resolvedProxyId,
|
||||
vpnId: resolvedVpnId,
|
||||
wayfernConfig: finalWayfernConfig,
|
||||
groupId:
|
||||
selectedGroupId && selectedGroupId !== "__all__"
|
||||
? selectedGroupId
|
||||
: undefined,
|
||||
extensionGroupId: selectedExtensionGroupId,
|
||||
ephemeral,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
launchHook: launchHook.trim() || undefined,
|
||||
password: passwordToSet,
|
||||
});
|
||||
} else {
|
||||
// Regular browser
|
||||
if (!selectedBrowser) {
|
||||
@@ -519,22 +470,19 @@ export function CreateProfileDialog({
|
||||
// Cancel any ongoing loading
|
||||
loadingBrowserRef.current = null;
|
||||
|
||||
// Reset all states
|
||||
// Reset all states. Stay on the Wayfern config step — Camoufox is
|
||||
// deprecated, so the browser-selection screen is gone.
|
||||
setProfileName("");
|
||||
setCurrentStep("browser-selection");
|
||||
setCurrentStep("browser-config");
|
||||
setActiveTab("anti-detect");
|
||||
setSelectedBrowser(null);
|
||||
setSelectedBrowser("wayfern");
|
||||
setSelectedProxyId(undefined);
|
||||
setLaunchHook("");
|
||||
setReleaseTypes({});
|
||||
setIsLoadingReleaseTypes(false);
|
||||
setReleaseTypesError(null);
|
||||
setCamoufoxConfig({
|
||||
geoip: true, // Reset to automatic geoip
|
||||
os: getCurrentOS(), // Reset to current OS
|
||||
});
|
||||
setWayfernConfig({
|
||||
os: getCurrentOS() as WayfernOS, // Reset to current OS
|
||||
os: getCurrentOS(), // Reset to current OS
|
||||
});
|
||||
setEphemeral(false);
|
||||
setEnablePassword(false);
|
||||
@@ -544,10 +492,6 @@ export function CreateProfileDialog({
|
||||
onClose();
|
||||
};
|
||||
|
||||
const updateCamoufoxConfig = (key: keyof CamoufoxConfig, value: unknown) => {
|
||||
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const updateWayfernConfig = (key: keyof WayfernConfig, value: unknown) => {
|
||||
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
@@ -652,46 +596,14 @@ export function CreateProfileDialog({
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Camoufox (Firefox) - Second */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleBrowserSelect("camoufox");
|
||||
}}
|
||||
disabled={!getCreatableVersion("camoufox")}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex justify-center items-center size-8">
|
||||
{isBrowserCurrentlyDownloading("camoufox") ? (
|
||||
<LuLoaderCircle className="size-6 animate-spin" />
|
||||
) : (
|
||||
(() => {
|
||||
const IconComponent =
|
||||
getBrowserIcon("camoufox");
|
||||
return IconComponent ? (
|
||||
<IconComponent className="size-6" />
|
||||
) : null;
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
{t("createProfile.firefoxLabel")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isBrowserCurrentlyDownloading("camoufox")
|
||||
? t("createProfile.downloadingSubtitle")
|
||||
: t("createProfile.firefoxSubtitle")}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
{/* Camoufox is deprecated — no longer offered for new
|
||||
profiles. Only Wayfern can be created. */}
|
||||
|
||||
{!getCreatableVersion("wayfern") &&
|
||||
!getCreatableVersion("camoufox") && (
|
||||
<p className="pt-2 text-sm text-center text-muted-foreground">
|
||||
{t("createProfile.browsersDownloading")}
|
||||
</p>
|
||||
)}
|
||||
{!getCreatableVersion("wayfern") && (
|
||||
<p className="pt-2 text-sm text-center text-muted-foreground">
|
||||
{t("createProfile.browsersDownloading")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -996,162 +908,9 @@ export function CreateProfileDialog({
|
||||
profileBrowser="wayfern"
|
||||
/>
|
||||
</div>
|
||||
) : selectedBrowser === "camoufox" ? (
|
||||
// Camoufox Configuration
|
||||
<div className="space-y-6">
|
||||
{/* Camoufox Download Status */}
|
||||
{isLoadingReleaseTypes && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("createProfile.version.fetching")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes && releaseTypesError && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-destructive/50 bg-destructive/10">
|
||||
<p className="flex-1 text-sm text-destructive">
|
||||
{releaseTypesError}
|
||||
</p>
|
||||
<RippleButton
|
||||
onClick={() =>
|
||||
selectedBrowser &&
|
||||
loadReleaseTypes(selectedBrowser)
|
||||
}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.buttons.retry")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
|
||||
<p className="text-sm text-warning">
|
||||
{t("createProfile.platformUnavailable", {
|
||||
browser: "Camoufox",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
!getCreatableVersion("camoufox") &&
|
||||
getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("createProfile.version.needsDownload", {
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
})}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
void handleDownload("camoufox");
|
||||
}}
|
||||
isLoading={isBrowserCurrentlyDownloading(
|
||||
"camoufox",
|
||||
)}
|
||||
size="sm"
|
||||
disabled={isBrowserCurrentlyDownloading(
|
||||
"camoufox",
|
||||
)}
|
||||
>
|
||||
{isBrowserCurrentlyDownloading("camoufox")
|
||||
? t("common.buttons.downloading")
|
||||
: t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
getCreatableVersion("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
✓{" "}
|
||||
{t("createProfile.version.available", {
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getCreatableVersion("camoufox")?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
getCreatableVersion("camoufox") &&
|
||||
!isBrowserVersionAvailable("camoufox") &&
|
||||
getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<p className="flex-1 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"createProfile.version.upgradeAvailable",
|
||||
{
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
void handleDownload("camoufox");
|
||||
}}
|
||||
isLoading={isBrowserCurrentlyDownloading(
|
||||
"camoufox",
|
||||
)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isBrowserCurrentlyDownloading(
|
||||
"camoufox",
|
||||
)}
|
||||
>
|
||||
{isBrowserCurrentlyDownloading("camoufox")
|
||||
? t("common.buttons.downloading")
|
||||
: t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{t("createProfile.version.downloading", {
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{crossOsUnlocked && (
|
||||
<Alert className="border-warning/50 bg-warning/10">
|
||||
<AlertDescription className="text-sm">
|
||||
{t("createProfile.camoufoxWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SharedCamoufoxConfigForm
|
||||
config={camoufoxConfig}
|
||||
onConfigChange={updateCamoufoxConfig}
|
||||
isCreating
|
||||
browserType="camoufox"
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
profileVersion={
|
||||
getCreatableVersion("camoufox")?.version
|
||||
}
|
||||
profileBrowser="camoufox"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// Regular Browser Configuration (should not happen in anti-detect tab)
|
||||
// Regular Browser Configuration (should not happen in
|
||||
// the anti-detect tab; Camoufox creation is removed).
|
||||
<div className="space-y-4">
|
||||
{selectedBrowser && (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -83,12 +83,7 @@ interface ErrorToastProps extends BaseToastProps {
|
||||
|
||||
interface DownloadToastProps extends BaseToastProps {
|
||||
type: "download";
|
||||
stage?:
|
||||
| "downloading"
|
||||
| "extracting"
|
||||
| "verifying"
|
||||
| "completed"
|
||||
| "downloading (twilight rolling release)";
|
||||
stage?: "downloading" | "extracting" | "verifying" | "completed";
|
||||
progress?: {
|
||||
percentage: number;
|
||||
speed?: string;
|
||||
@@ -111,12 +106,6 @@ interface FetchingToastProps extends BaseToastProps {
|
||||
browserName?: string;
|
||||
}
|
||||
|
||||
interface TwilightUpdateToastProps extends BaseToastProps {
|
||||
type: "twilight-update";
|
||||
browserName?: string;
|
||||
hasUpdate?: boolean;
|
||||
}
|
||||
|
||||
interface SyncProgressToastProps extends BaseToastProps {
|
||||
type: "sync-progress";
|
||||
progress?: {
|
||||
@@ -138,7 +127,6 @@ type ToastProps =
|
||||
| DownloadToastProps
|
||||
| VersionUpdateToastProps
|
||||
| FetchingToastProps
|
||||
| TwilightUpdateToastProps
|
||||
| SyncProgressToastProps;
|
||||
|
||||
function formatBytesCompact(bytes: number): string {
|
||||
@@ -191,10 +179,6 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
return (
|
||||
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
||||
);
|
||||
case "twilight-update":
|
||||
return (
|
||||
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
||||
);
|
||||
case "sync-progress":
|
||||
return (
|
||||
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
||||
@@ -246,7 +230,8 @@ export function UnifiedToast(props: ToastProps) {
|
||||
<p className="flex-1 min-w-0 text-xs text-muted-foreground">
|
||||
{progress.percentage.toFixed(1)}%
|
||||
{progress.speed && ` • ${progress.speed} MB/s`}
|
||||
{progress.eta && ` • ${progress.eta} remaining`}
|
||||
{progress.eta &&
|
||||
` • ${t("toasts.progress.remaining", { time: progress.eta })}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-1.5">
|
||||
@@ -264,9 +249,10 @@ export function UnifiedToast(props: ToastProps) {
|
||||
"current_browser" in progress && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress.current_browser && (
|
||||
<>Looking for updates for {progress.current_browser}</>
|
||||
)}
|
||||
{progress.current_browser &&
|
||||
t("versionUpdater.toast.lookingForUpdates", {
|
||||
browser: progress.current_browser,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="flex-1 bg-muted rounded-full h-1.5 min-w-0">
|
||||
@@ -293,7 +279,10 @@ export function UnifiedToast(props: ToastProps) {
|
||||
{progress.phase === "uploading"
|
||||
? t("appUpdate.toast.uploading")
|
||||
: t("appUpdate.toast.downloading")}{" "}
|
||||
{progress.completed_files}/{progress.total_files} files
|
||||
{t("toasts.progress.filesProgress", {
|
||||
completed: progress.completed_files,
|
||||
total: progress.total_files,
|
||||
})}
|
||||
{" \u2022 "}
|
||||
{formatBytesCompact(progress.completed_bytes)} /{" "}
|
||||
{formatBytesCompact(progress.total_bytes)}
|
||||
@@ -304,37 +293,21 @@ export function UnifiedToast(props: ToastProps) {
|
||||
</>
|
||||
)}
|
||||
{progress.eta_seconds > 0 &&
|
||||
progress.completed_files < progress.total_files && (
|
||||
<>
|
||||
{" \u2022 ~"}
|
||||
{formatEtaCompact(progress.eta_seconds)} remaining
|
||||
</>
|
||||
)}
|
||||
progress.completed_files < progress.total_files &&
|
||||
` \u2022 ${t("toasts.progress.remaining", {
|
||||
time: `~${formatEtaCompact(progress.eta_seconds)}`,
|
||||
})}`}
|
||||
</p>
|
||||
{progress.failed_count > 0 && (
|
||||
<p className="text-xs text-destructive mt-0.5">
|
||||
{progress.failed_count} file(s) failed
|
||||
{t("toasts.progress.filesFailed", {
|
||||
count: progress.failed_count,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Twilight update progress */}
|
||||
{type === "twilight-update" && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{"hasUpdate" in props && props.hasUpdate
|
||||
? "New twilight build available for download"
|
||||
: "Checking for twilight updates..."}
|
||||
</p>
|
||||
{props.browserName && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{props.browserName} • Rolling Release
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="mt-1 text-xs leading-tight text-muted-foreground">
|
||||
@@ -355,11 +328,6 @@ export function UnifiedToast(props: ToastProps) {
|
||||
{t("browserDownload.toast.verifying")}
|
||||
</p>
|
||||
)}
|
||||
{stage === "downloading (twilight rolling release)" && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("browserDownload.toast.downloadingRolling")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{action &&
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { FaFolder } from "react-icons/fa";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AnimatedTabs,
|
||||
@@ -34,9 +33,10 @@ import {
|
||||
import { WayfernConfigForm } from "@/components/wayfern-config-form";
|
||||
import { useBrowserSupport } from "@/hooks/use-browser-support";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CamoufoxConfig, DetectedProfile, WayfernConfig } from "@/types";
|
||||
import type { DetectedProfile, WayfernConfig } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
const getMappedBrowser = (browser: string): "camoufox" | "wayfern" => {
|
||||
@@ -70,7 +70,6 @@ export function ImportProfileDialog({
|
||||
const [currentStep, setCurrentStep] = useState<"select" | "configure">(
|
||||
"select",
|
||||
);
|
||||
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({});
|
||||
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>({});
|
||||
const [selectedProxyId, setSelectedProxyId] = useState<string | undefined>();
|
||||
|
||||
@@ -91,7 +90,11 @@ export function ImportProfileDialog({
|
||||
useBrowserSupport();
|
||||
const { storedProxies } = useProxyEvents();
|
||||
|
||||
const importableBrowsers = supportedBrowsers;
|
||||
// Firefox-based browsers map to the deprecated Camoufox and can no longer be
|
||||
// imported (the backend rejects them); only offer Chromium-family sources.
|
||||
const importableBrowsers = supportedBrowsers.filter(
|
||||
(browser) => getMappedBrowser(browser) === "wayfern",
|
||||
);
|
||||
|
||||
const loadDetectedProfiles = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -176,7 +179,7 @@ export function ImportProfileDialog({
|
||||
|
||||
const mappedBrowser =
|
||||
importMode === "auto-detect" && selectedProfile
|
||||
? (selectedProfile.mapped_browser as "camoufox" | "wayfern")
|
||||
? getMappedBrowser(selectedProfile.mapped_browser)
|
||||
: getMappedBrowser(browserType);
|
||||
|
||||
setIsImporting(true);
|
||||
@@ -186,7 +189,8 @@ export function ImportProfileDialog({
|
||||
browserType,
|
||||
newProfileName,
|
||||
proxyId: selectedProxyId ?? null,
|
||||
camoufoxConfig: mappedBrowser === "camoufox" ? camoufoxConfig : null,
|
||||
// Camoufox import is deprecated/blocked; only Wayfern configs are sent.
|
||||
camoufoxConfig: null,
|
||||
wayfernConfig: mappedBrowser === "wayfern" ? wayfernConfig : null,
|
||||
});
|
||||
|
||||
@@ -199,7 +203,10 @@ export function ImportProfileDialog({
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (errorMessage.includes("No downloaded versions found")) {
|
||||
if (parseBackendError(error)) {
|
||||
// Structured backend error (e.g. CAMOUFOX_IMPORT_DEPRECATED) — localize.
|
||||
toast.error(translateBackendError(t, error));
|
||||
} else if (errorMessage.includes("No downloaded versions found")) {
|
||||
const browserDisplayName = getBrowserDisplayName(browserType);
|
||||
toast.error(
|
||||
t("importProfile.notInstalled", { browser: browserDisplayName }),
|
||||
@@ -222,7 +229,6 @@ export function ImportProfileDialog({
|
||||
manualProfilePath,
|
||||
manualProfileName,
|
||||
selectedProxyId,
|
||||
camoufoxConfig,
|
||||
wayfernConfig,
|
||||
onClose,
|
||||
selectedProfile,
|
||||
@@ -231,7 +237,6 @@ export function ImportProfileDialog({
|
||||
|
||||
const handleClose = () => {
|
||||
setCurrentStep("select");
|
||||
setCamoufoxConfig({});
|
||||
setWayfernConfig({});
|
||||
setSelectedProxyId(undefined);
|
||||
setSelectedDetectedProfile(null);
|
||||
@@ -262,10 +267,10 @@ export function ImportProfileDialog({
|
||||
|
||||
const currentMappedBrowser = useMemo(() => {
|
||||
if (importMode === "auto-detect" && selectedProfile) {
|
||||
return selectedProfile.mapped_browser as "camoufox" | "wayfern";
|
||||
return getMappedBrowser(selectedProfile.mapped_browser);
|
||||
}
|
||||
if (importMode === "manual" && manualBrowserType) {
|
||||
return manualBrowserType as "camoufox" | "wayfern";
|
||||
return getMappedBrowser(manualBrowserType);
|
||||
}
|
||||
return null;
|
||||
}, [importMode, selectedProfile, manualBrowserType]);
|
||||
@@ -577,27 +582,17 @@ export function ImportProfileDialog({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{currentMappedBrowser === "camoufox" ? (
|
||||
<SharedCamoufoxConfigForm
|
||||
config={camoufoxConfig}
|
||||
onConfigChange={(key, value) => {
|
||||
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
|
||||
}}
|
||||
isCreating={true}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
) : (
|
||||
<WayfernConfigForm
|
||||
config={wayfernConfig}
|
||||
onConfigChange={(key, value) => {
|
||||
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
||||
}}
|
||||
isCreating={true}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
)}
|
||||
{/* Only Wayfern profiles are importable now (Camoufox/Firefox
|
||||
import is deprecated and blocked). */}
|
||||
<WayfernConfigForm
|
||||
config={wayfernConfig}
|
||||
onConfigChange={(key, value) => {
|
||||
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
||||
}}
|
||||
isCreating={true}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2039,12 +2039,12 @@ export function ProfilesDataTable({
|
||||
|
||||
if (isDisabled) {
|
||||
const tooltipMessage = isRunning
|
||||
? "Can't modify running profile"
|
||||
? t("profiles.table.cantModifyRunning")
|
||||
: isLaunching
|
||||
? "Can't modify profile while launching"
|
||||
? t("profiles.table.cantModifyLaunching")
|
||||
: isStopping
|
||||
? "Can't modify profile while stopping"
|
||||
: "Can't modify profile while browser is updating";
|
||||
? t("profiles.table.cantModifyStopping")
|
||||
: t("profiles.table.cantModifyUpdating");
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
LuClipboardCheck,
|
||||
LuCookie,
|
||||
LuCopy,
|
||||
LuDownload,
|
||||
LuFingerprint,
|
||||
LuGlobe,
|
||||
LuGroup,
|
||||
@@ -39,6 +42,12 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
@@ -263,9 +272,9 @@ export function ProfileInfoDialog({
|
||||
? vpnConfigs.find((v) => v.id === profile.vpn_id)?.name
|
||||
: null;
|
||||
const networkLabel = vpnName
|
||||
? `VPN: ${vpnName}`
|
||||
? t("profileInfo.network.vpnLabel", { name: vpnName })
|
||||
: proxyName
|
||||
? `Proxy: ${proxyName}`
|
||||
? t("profileInfo.network.proxyLabel", { name: proxyName })
|
||||
: t("profileInfo.values.none");
|
||||
|
||||
const syncStatus = syncStatuses[profile.id];
|
||||
@@ -299,6 +308,10 @@ export function ProfileInfoDialog({
|
||||
// `ProfileDnsBlocklistDialog` for the pattern). The settings tab is purely
|
||||
// a navigation hub.
|
||||
interface ActionItem {
|
||||
// Stable, language-independent key used to map sidebar sections to actions.
|
||||
// The sidebar must NOT match on `label` — labels are translated, so English
|
||||
// substring matching hides sections for every non-English user.
|
||||
id?: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
@@ -311,6 +324,7 @@ export function ProfileInfoDialog({
|
||||
|
||||
const actions: ActionItem[] = [
|
||||
{
|
||||
id: "network",
|
||||
icon: <LuGlobe className="size-4" />,
|
||||
label: t("profiles.actions.viewNetwork"),
|
||||
onClick: () => {
|
||||
@@ -319,6 +333,7 @@ export function ProfileInfoDialog({
|
||||
disabled: isCrossOs,
|
||||
},
|
||||
{
|
||||
id: "sync",
|
||||
icon: <LuRefreshCw className="size-4" />,
|
||||
label: t("profiles.actions.syncSettings"),
|
||||
onClick: () => {
|
||||
@@ -337,6 +352,7 @@ export function ProfileInfoDialog({
|
||||
runningBadge: isRunning,
|
||||
},
|
||||
{
|
||||
id: "fingerprint",
|
||||
icon: <LuFingerprint className="size-4" />,
|
||||
label: t("profiles.actions.changeFingerprint"),
|
||||
onClick: () => {
|
||||
@@ -359,6 +375,7 @@ export function ProfileInfoDialog({
|
||||
hidden: profile.browser !== "wayfern" || !onLaunchWithSync,
|
||||
},
|
||||
{
|
||||
id: "cookiesCopy",
|
||||
icon: <LuCopy className="size-4" />,
|
||||
label: t("profiles.actions.copyCookiesToProfile"),
|
||||
onClick: () => {
|
||||
@@ -372,6 +389,7 @@ export function ProfileInfoDialog({
|
||||
!onCopyCookiesToProfile,
|
||||
},
|
||||
{
|
||||
id: "cookiesManage",
|
||||
icon: <LuCookie className="size-4" />,
|
||||
label: t("profileInfo.actions.manageCookies"),
|
||||
onClick: () => {
|
||||
@@ -395,6 +413,7 @@ export function ProfileInfoDialog({
|
||||
hidden: profile.ephemeral === true,
|
||||
},
|
||||
{
|
||||
id: "extension",
|
||||
icon: <LuPuzzle className="size-4" />,
|
||||
label: t("profileInfo.actions.assignExtensionGroup"),
|
||||
onClick: () => {
|
||||
@@ -419,6 +438,7 @@ export function ProfileInfoDialog({
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "hook",
|
||||
icon: <LuLink className="size-4" />,
|
||||
label: t("profiles.actions.launchHook"),
|
||||
onClick: () => {
|
||||
@@ -461,6 +481,7 @@ export function ProfileInfoDialog({
|
||||
destructive: true,
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
icon: <LuTrash2 className="size-4" />,
|
||||
label: t("profiles.actions.delete"),
|
||||
onClick: () => {
|
||||
@@ -534,6 +555,7 @@ interface ProfileInfoLayoutProps {
|
||||
onCloneProfile?: (profile: BrowserProfile) => void;
|
||||
onKillProfile?: (profile: BrowserProfile) => void;
|
||||
visibleActions: {
|
||||
id?: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
@@ -579,22 +601,23 @@ function ProfileInfoLayout({
|
||||
}: ProfileInfoLayoutProps) {
|
||||
const [section, setSection] = React.useState<ProfileSection>("overview");
|
||||
|
||||
// Map sidebar items to existing action labels, so clicking a section
|
||||
// simply triggers the existing dialog handler.
|
||||
// Map sidebar items to existing actions by their stable, language-independent
|
||||
// `id`, so clicking a section triggers the existing dialog handler. Matching
|
||||
// on `label` would break for every non-English locale (the labels are
|
||||
// translated) and hide whole sections.
|
||||
const findAction = React.useCallback(
|
||||
(substr: string) =>
|
||||
visibleActions.find((a) => a.label.toLowerCase().includes(substr)),
|
||||
(id: string) => visibleActions.find((a) => a.id === id),
|
||||
[visibleActions],
|
||||
);
|
||||
|
||||
const deleteAction = findAction("delete");
|
||||
const fingerprintAction = findAction("fingerprint");
|
||||
const cookiesManageAction = findAction("manage cookies");
|
||||
const cookiesCopyAction = findAction("copy cookies");
|
||||
const cookiesManageAction = findAction("cookiesManage");
|
||||
const cookiesCopyAction = findAction("cookiesCopy");
|
||||
const cookiesAction = cookiesManageAction ?? cookiesCopyAction;
|
||||
const extensionAction = findAction("extension");
|
||||
const syncAction = findAction("sync");
|
||||
const _launchHookAction = findAction("hook") ?? findAction("launch hook");
|
||||
const _launchHookAction = findAction("hook");
|
||||
const _networkAction = findAction("network");
|
||||
// Password actions are no longer routed via the legacy action handlers —
|
||||
// SecuritySectionInline writes directly to the backend instead.
|
||||
@@ -1149,7 +1172,7 @@ function SyncSectionInline({
|
||||
syncMode: mode,
|
||||
});
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setError(translateBackendError(t as never, e));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -1192,7 +1215,9 @@ function SyncSectionInline({
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("profileInfo.fields.syncStatus")}
|
||||
</p>
|
||||
<p className="text-sm mt-0.5">{syncStatus.status}</p>
|
||||
<p className="text-sm mt-0.5">
|
||||
{t(`profileInfo.syncStatusValue.${syncStatus.status}`)}
|
||||
</p>
|
||||
{syncStatus.error && (
|
||||
<p className="text-xs text-destructive mt-1">{syncStatus.error}</p>
|
||||
)}
|
||||
@@ -1246,7 +1271,7 @@ function NetworkSectionInline({
|
||||
setProxyId(nextId);
|
||||
if (nextId !== null) setVpnId(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setError(translateBackendError(t as never, e));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -1264,7 +1289,7 @@ function NetworkSectionInline({
|
||||
setVpnId(nextId);
|
||||
if (nextId !== null) setProxyId(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setError(translateBackendError(t as never, e));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -1370,7 +1395,7 @@ function ExtensionsSectionInline({
|
||||
);
|
||||
if (mounted) setGroups(data);
|
||||
} catch (e) {
|
||||
if (mounted) setError(String(e));
|
||||
if (mounted) setError(translateBackendError(t as never, e));
|
||||
}
|
||||
};
|
||||
void load();
|
||||
@@ -1384,7 +1409,7 @@ function ExtensionsSectionInline({
|
||||
mounted = false;
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const onChange = async (value: string) => {
|
||||
const next = value === "__none__" ? null : value;
|
||||
@@ -1397,7 +1422,7 @@ function ExtensionsSectionInline({
|
||||
});
|
||||
setGroupId(next);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setError(translateBackendError(t as never, e));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -1495,6 +1520,41 @@ function CookiesSectionInline({
|
||||
};
|
||||
}, [profile.id, isRunning, t]);
|
||||
|
||||
const [isExporting, setIsExporting] = React.useState(false);
|
||||
|
||||
// Export all of this profile's cookies in one of the same formats import
|
||||
// accepts (JSON or Netscape). The backend formats every cookie; we just pick
|
||||
// a destination file.
|
||||
const handleExport = React.useCallback(
|
||||
async (format: "json" | "netscape") => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const content = await invoke<string>("export_profile_cookies", {
|
||||
profileId: profile.id,
|
||||
format,
|
||||
});
|
||||
const ext = format === "json" ? "json" : "txt";
|
||||
const filePath = await save({
|
||||
defaultPath: `${profile.name}_cookies.${ext}`,
|
||||
filters: [
|
||||
{
|
||||
name: format === "json" ? "JSON" : "Text",
|
||||
extensions: [ext],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!filePath) return;
|
||||
await writeTextFile(filePath, content);
|
||||
showSuccessToast(t("cookies.export.success"));
|
||||
} catch (e) {
|
||||
showErrorToast(translateBackendError(t as never, e));
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
},
|
||||
[profile.id, profile.name, t],
|
||||
);
|
||||
|
||||
const domains = stats?.domains ?? [];
|
||||
|
||||
return (
|
||||
@@ -1505,6 +1565,41 @@ function CookiesSectionInline({
|
||||
{t("profileInfo.sections.cookies")}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5"
|
||||
disabled={
|
||||
isDisabled ||
|
||||
isRunning ||
|
||||
isExporting ||
|
||||
!stats ||
|
||||
stats.total_count === 0
|
||||
}
|
||||
>
|
||||
<LuDownload className="size-3.5" />
|
||||
{t("common.buttons.export")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void handleExport("json");
|
||||
}}
|
||||
>
|
||||
{t("cookies.export.json")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void handleExport("netscape");
|
||||
}}
|
||||
>
|
||||
{t("cookies.export.netscape")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{onImportCookies && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1514,7 +1609,7 @@ function CookiesSectionInline({
|
||||
onClick={onImportCookies}
|
||||
>
|
||||
<LuUpload className="size-3.5" />
|
||||
{t("cookies.import.title")}
|
||||
{t("common.buttons.import")}
|
||||
</Button>
|
||||
)}
|
||||
{onCopyCookies && (
|
||||
@@ -1526,7 +1621,7 @@ function CookiesSectionInline({
|
||||
onClick={onCopyCookies}
|
||||
>
|
||||
<LuCopy className="size-3.5" />
|
||||
{t("profiles.actions.copyCookies")}
|
||||
{t("common.buttons.copy")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1684,7 +1779,7 @@ function FingerprintSectionInline({
|
||||
// Close the dialog once the fingerprint is saved.
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setError(translateBackendError(t as never, e));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ export function ProfilePasswordDialog({
|
||||
<div className="flex flex-col gap-3">
|
||||
{(mode === "set" || mode === "change") && (
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">
|
||||
<p className="font-medium text-warning-foreground">
|
||||
<p className="font-medium text-warning">
|
||||
{t("profilePassword.warnings.forgetWarningTitle")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { getEntitlements } from "@/lib/entitlements";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, SyncMode, SyncSettings } from "@/types";
|
||||
import { isSyncEnabled } from "@/types";
|
||||
@@ -36,11 +37,7 @@ export function ProfileSyncDialog({
|
||||
}: ProfileSyncDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user: cloudUser } = useCloudAuth();
|
||||
const isCloudSyncEligible =
|
||||
cloudUser != null &&
|
||||
cloudUser.plan !== "free" &&
|
||||
(cloudUser.subscriptionStatus === "active" ||
|
||||
cloudUser.planPeriod === "lifetime");
|
||||
const isCloudSyncEligible = getEntitlements(cloudUser).cloudBackup;
|
||||
// Encryption available to everyone except team members who aren't owners
|
||||
const canUseEncryption =
|
||||
cloudUser == null ||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -239,7 +239,11 @@
|
||||
"extDefault": "Default",
|
||||
"dnsLevel": "DNS blocklist: {{level}}",
|
||||
"extSearch": "Search groups…",
|
||||
"extEmpty": "No extension groups"
|
||||
"extEmpty": "No extension groups",
|
||||
"cantModifyRunning": "Can't modify running profile",
|
||||
"cantModifyLaunching": "Can't modify profile while launching",
|
||||
"cantModifyStopping": "Can't modify profile while stopping",
|
||||
"cantModifyUpdating": "Can't modify profile while browser is updating"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Launch",
|
||||
@@ -639,7 +643,11 @@
|
||||
"profileSynced": "Profile '{{name}}' synced successfully",
|
||||
"profileSyncFailed": "Failed to sync profile '{{name}}'",
|
||||
"profileSyncFailedWithError": "Failed to sync profile '{{name}}': {{error}}"
|
||||
}
|
||||
},
|
||||
"enabledToast": "Sync enabled",
|
||||
"disabledToast": "Sync disabled",
|
||||
"enabledDescription": "Profile sync has been enabled",
|
||||
"disabledDescription": "Profile sync has been disabled"
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrations",
|
||||
@@ -918,6 +926,11 @@
|
||||
"syncingProfile": "Syncing profile '{{name}}'...",
|
||||
"syncingProfileWithProgress": "{{count}} files ({{size}})",
|
||||
"updatingVersions": "Updating browser versions..."
|
||||
},
|
||||
"progress": {
|
||||
"remaining": "{{time}} remaining",
|
||||
"filesProgress": "{{completed}}/{{total}} files",
|
||||
"filesFailed": "{{count}} file(s) failed"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
@@ -1136,7 +1149,9 @@
|
||||
"addRule": "Add Rule",
|
||||
"rulePlaceholder": "e.g. example.com, 192.168.1.*, .*\\.local",
|
||||
"noRules": "No bypass rules configured.",
|
||||
"ruleTypes": "Supports hostnames, IP addresses, and regex patterns."
|
||||
"ruleTypes": "Supports hostnames, IP addresses, and regex patterns.",
|
||||
"vpnLabel": "VPN: {{name}}",
|
||||
"proxyLabel": "Proxy: {{name}}"
|
||||
},
|
||||
"launchHook": {
|
||||
"title": "Launch Hook URL",
|
||||
@@ -1196,8 +1211,14 @@
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"syncStatusValue": {
|
||||
"waiting": "Waiting",
|
||||
"syncing": "Syncing",
|
||||
"synced": "Synced",
|
||||
"error": "Error"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1718,7 +1739,8 @@
|
||||
"updateStartedDescription": "Version {{version}} download will begin shortly. Browser launch is disabled until update completes.",
|
||||
"downloadStarting": "Starting {{browser}} {{version}} download",
|
||||
"downloadProgressBelow": "Download progress will be shown below...",
|
||||
"autoDownloadStarted": "Downloading {{browser}} {{version}} automatically. Progress will be shown below."
|
||||
"autoDownloadStarted": "Downloading {{browser}} {{version}} automatically. Progress will be shown below.",
|
||||
"lookingForUpdates": "Looking for updates for {{browser}}"
|
||||
}
|
||||
},
|
||||
"profilePassword": {
|
||||
@@ -1807,10 +1829,11 @@
|
||||
"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."
|
||||
"vpnNotWorking": "The selected VPN isn't working, so the profile wasn't created.",
|
||||
"camoufoxImportDeprecated": "Importing Firefox-based (Camoufox) profiles is no longer supported. Camoufox is being deprecated — please use Wayfern instead."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiles",
|
||||
@@ -1939,7 +1962,7 @@
|
||||
},
|
||||
"browserSupport": {
|
||||
"endingSoonTitle": "Browser support ending soon",
|
||||
"endingSoonDescription": "Support for the following profiles will be removed on March 15, 2026: {{profiles}}. Please migrate to Wayfern or Camoufox profiles."
|
||||
"endingSoonDescription": "Support for the following profiles will be removed on March 15, 2026: {{profiles}}. Please migrate to Wayfern profiles."
|
||||
},
|
||||
"onboarding": {
|
||||
"steps": {
|
||||
@@ -2017,5 +2040,14 @@
|
||||
"trialBadge": "2 weeks free",
|
||||
"commercialDesc": "Free for a 2-week evaluation. After that, a paid plan keeps the project maintained and thriving."
|
||||
}
|
||||
},
|
||||
"wayfernBlocked": {
|
||||
"title": "Browser automation paused",
|
||||
"description": "Your account was temporarily restricted from Pro browser features, usually from signing in on multiple devices at once. Sign out of other devices, then relaunch the profile to restore it."
|
||||
},
|
||||
"camoufoxDeprecation": {
|
||||
"title": "Camoufox support is ending",
|
||||
"description": "Support for Camoufox profiles is ending on July 8, 2026. You have one or more Camoufox profiles. Please migrate them to Wayfern before then — after that date, Camoufox profiles may stop working.",
|
||||
"acknowledge": "Got it"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,11 @@
|
||||
"extDefault": "Predet.",
|
||||
"dnsLevel": "Lista DNS: {{level}}",
|
||||
"extSearch": "Buscar grupos…",
|
||||
"extEmpty": "Sin grupos de extensiones"
|
||||
"extEmpty": "Sin grupos de extensiones",
|
||||
"cantModifyRunning": "No se puede modificar un perfil en ejecución",
|
||||
"cantModifyLaunching": "No se puede modificar el perfil mientras se inicia",
|
||||
"cantModifyStopping": "No se puede modificar el perfil mientras se detiene",
|
||||
"cantModifyUpdating": "No se puede modificar el perfil mientras se actualiza el navegador"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Iniciar",
|
||||
@@ -639,7 +643,11 @@
|
||||
"profileSynced": "Perfil '{{name}}' sincronizado correctamente",
|
||||
"profileSyncFailed": "Error al sincronizar el perfil '{{name}}'",
|
||||
"profileSyncFailedWithError": "Error al sincronizar el perfil '{{name}}': {{error}}"
|
||||
}
|
||||
},
|
||||
"enabledToast": "Sincronización activada",
|
||||
"disabledToast": "Sincronización desactivada",
|
||||
"enabledDescription": "Se ha activado la sincronización del perfil",
|
||||
"disabledDescription": "Se ha desactivado la sincronización del perfil"
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integraciones",
|
||||
@@ -918,6 +926,11 @@
|
||||
"syncingProfile": "Sincronizando perfil '{{name}}'...",
|
||||
"syncingProfileWithProgress": "{{count}} archivos ({{size}})",
|
||||
"updatingVersions": "Actualizando versiones de navegadores..."
|
||||
},
|
||||
"progress": {
|
||||
"remaining": "{{time}} restante",
|
||||
"filesProgress": "{{completed}}/{{total}} archivos",
|
||||
"filesFailed": "{{count}} archivo(s) con error"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
@@ -1136,7 +1149,9 @@
|
||||
"addRule": "Agregar Regla",
|
||||
"rulePlaceholder": "ej. example.com, 192.168.1.*, .*\\.local",
|
||||
"noRules": "No hay reglas de omisión configuradas.",
|
||||
"ruleTypes": "Soporta nombres de host, direcciones IP y patrones regex."
|
||||
"ruleTypes": "Soporta nombres de host, direcciones IP y patrones regex.",
|
||||
"vpnLabel": "VPN: {{name}}",
|
||||
"proxyLabel": "Proxy: {{name}}"
|
||||
},
|
||||
"launchHook": {
|
||||
"title": "URL del hook de inicio",
|
||||
@@ -1196,8 +1211,14 @@
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"syncStatusValue": {
|
||||
"waiting": "Esperando",
|
||||
"syncing": "Sincronizando",
|
||||
"synced": "Sincronizado",
|
||||
"error": "Error"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1718,7 +1739,8 @@
|
||||
"updateStartedDescription": "La descarga de la versión {{version}} comenzará en breve. El inicio del navegador está deshabilitado hasta que finalice la actualización.",
|
||||
"downloadStarting": "Iniciando la descarga de {{browser}} {{version}}",
|
||||
"downloadProgressBelow": "El progreso de la descarga se mostrará a continuación...",
|
||||
"autoDownloadStarted": "Descargando {{browser}} {{version}} automáticamente. El progreso se mostrará a continuación."
|
||||
"autoDownloadStarted": "Descargando {{browser}} {{version}} automáticamente. El progreso se mostrará a continuación.",
|
||||
"lookingForUpdates": "Buscando actualizaciones de {{browser}}"
|
||||
}
|
||||
},
|
||||
"profilePassword": {
|
||||
@@ -1807,10 +1829,11 @@
|
||||
"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."
|
||||
"vpnNotWorking": "La VPN seleccionada no funciona, por lo que no se creó el perfil.",
|
||||
"camoufoxImportDeprecated": "La importación de perfiles basados en Firefox (Camoufox) ya no es compatible. Camoufox está en desuso; usa Wayfern en su lugar."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfiles",
|
||||
@@ -1939,7 +1962,7 @@
|
||||
},
|
||||
"browserSupport": {
|
||||
"endingSoonTitle": "El soporte del navegador finalizará pronto",
|
||||
"endingSoonDescription": "El soporte para los siguientes perfiles se eliminará el 15 de marzo de 2026: {{profiles}}. Migra a perfiles de Wayfern o Camoufox."
|
||||
"endingSoonDescription": "El soporte para los siguientes perfiles se eliminará el 15 de marzo de 2026: {{profiles}}. Migra a perfiles de Wayfern."
|
||||
},
|
||||
"onboarding": {
|
||||
"steps": {
|
||||
@@ -2017,5 +2040,14 @@
|
||||
"trialBadge": "2 semanas gratis",
|
||||
"commercialDesc": "Gratis durante una evaluación de 2 semanas. Después, un plan de pago mantiene el proyecto en buen estado y próspero."
|
||||
}
|
||||
},
|
||||
"wayfernBlocked": {
|
||||
"title": "Automatización del navegador en pausa",
|
||||
"description": "Tu cuenta fue restringida temporalmente de las funciones Pro del navegador, normalmente por iniciar sesión en varios dispositivos a la vez. Cierra sesión en los demás dispositivos y vuelve a iniciar el perfil para restaurarla."
|
||||
},
|
||||
"camoufoxDeprecation": {
|
||||
"title": "El soporte para Camoufox está terminando",
|
||||
"description": "El soporte para los perfiles de Camoufox terminará el 8 de julio de 2026. Tienes uno o más perfiles de Camoufox. Migra a Wayfern antes de esa fecha; después, los perfiles de Camoufox podrían dejar de funcionar.",
|
||||
"acknowledge": "Entendido"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,11 @@
|
||||
"extDefault": "Défaut",
|
||||
"dnsLevel": "Liste DNS : {{level}}",
|
||||
"extSearch": "Rechercher des groupes…",
|
||||
"extEmpty": "Aucun groupe d’extensions"
|
||||
"extEmpty": "Aucun groupe d’extensions",
|
||||
"cantModifyRunning": "Impossible de modifier un profil en cours d'exécution",
|
||||
"cantModifyLaunching": "Impossible de modifier le profil pendant le lancement",
|
||||
"cantModifyStopping": "Impossible de modifier le profil pendant l'arrêt",
|
||||
"cantModifyUpdating": "Impossible de modifier le profil pendant la mise à jour du navigateur"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Lancer",
|
||||
@@ -639,7 +643,11 @@
|
||||
"profileSynced": "Profil '{{name}}' synchronisé avec succès",
|
||||
"profileSyncFailed": "Échec de la synchronisation du profil '{{name}}'",
|
||||
"profileSyncFailedWithError": "Échec de la synchronisation du profil '{{name}}' : {{error}}"
|
||||
}
|
||||
},
|
||||
"enabledToast": "Synchronisation activée",
|
||||
"disabledToast": "Synchronisation désactivée",
|
||||
"enabledDescription": "La synchronisation du profil a été activée",
|
||||
"disabledDescription": "La synchronisation du profil a été désactivée"
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Intégrations",
|
||||
@@ -918,6 +926,11 @@
|
||||
"syncingProfile": "Synchronisation du profil '{{name}}'...",
|
||||
"syncingProfileWithProgress": "{{count}} fichiers ({{size}})",
|
||||
"updatingVersions": "Mise à jour des versions de navigateurs..."
|
||||
},
|
||||
"progress": {
|
||||
"remaining": "{{time}} restant",
|
||||
"filesProgress": "{{completed}}/{{total}} fichiers",
|
||||
"filesFailed": "Échec de {{count}} fichier(s)"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
@@ -1136,7 +1149,9 @@
|
||||
"addRule": "Ajouter une Règle",
|
||||
"rulePlaceholder": "ex. example.com, 192.168.1.*, .*\\.local",
|
||||
"noRules": "Aucune règle de contournement configurée.",
|
||||
"ruleTypes": "Prend en charge les noms d'hôte, les adresses IP et les expressions régulières."
|
||||
"ruleTypes": "Prend en charge les noms d'hôte, les adresses IP et les expressions régulières.",
|
||||
"vpnLabel": "VPN : {{name}}",
|
||||
"proxyLabel": "Proxy : {{name}}"
|
||||
},
|
||||
"launchHook": {
|
||||
"title": "URL du hook de lancement",
|
||||
@@ -1196,8 +1211,14 @@
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"syncStatusValue": {
|
||||
"waiting": "En attente",
|
||||
"syncing": "Synchronisation",
|
||||
"synced": "Synchronisé",
|
||||
"error": "Erreur"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1718,7 +1739,8 @@
|
||||
"updateStartedDescription": "Le téléchargement de la version {{version}} va bientôt commencer. Le lancement du navigateur est désactivé jusqu'à la fin de la mise à jour.",
|
||||
"downloadStarting": "Démarrage du téléchargement de {{browser}} {{version}}",
|
||||
"downloadProgressBelow": "La progression du téléchargement sera affichée ci-dessous...",
|
||||
"autoDownloadStarted": "Téléchargement automatique de {{browser}} {{version}}. La progression sera affichée ci-dessous."
|
||||
"autoDownloadStarted": "Téléchargement automatique de {{browser}} {{version}}. La progression sera affichée ci-dessous.",
|
||||
"lookingForUpdates": "Recherche de mises à jour pour {{browser}}"
|
||||
}
|
||||
},
|
||||
"profilePassword": {
|
||||
@@ -1807,10 +1829,11 @@
|
||||
"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éé."
|
||||
"vpnNotWorking": "Le VPN sélectionné ne fonctionne pas, le profil n'a donc pas été créé.",
|
||||
"camoufoxImportDeprecated": "L'importation de profils basés sur Firefox (Camoufox) n'est plus prise en charge. Camoufox est en cours d'abandon — utilisez Wayfern à la place."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profils",
|
||||
@@ -1939,7 +1962,7 @@
|
||||
},
|
||||
"browserSupport": {
|
||||
"endingSoonTitle": "La prise en charge du navigateur prend bientôt fin",
|
||||
"endingSoonDescription": "La prise en charge des profils suivants sera supprimée le 15 mars 2026 : {{profiles}}. Veuillez migrer vers des profils Wayfern ou Camoufox."
|
||||
"endingSoonDescription": "La prise en charge des profils suivants sera supprimée le 15 mars 2026 : {{profiles}}. Veuillez migrer vers des profils Wayfern."
|
||||
},
|
||||
"onboarding": {
|
||||
"steps": {
|
||||
@@ -2017,5 +2040,14 @@
|
||||
"trialBadge": "2 semaines gratuites",
|
||||
"commercialDesc": "Gratuit pendant une évaluation de 2 semaines. Ensuite, un forfait payant permet de maintenir et de faire prospérer le projet."
|
||||
}
|
||||
},
|
||||
"wayfernBlocked": {
|
||||
"title": "Automatisation du navigateur en pause",
|
||||
"description": "Votre compte a été temporairement privé des fonctionnalités Pro du navigateur, généralement à cause d'une connexion sur plusieurs appareils à la fois. Déconnectez-vous des autres appareils, puis relancez le profil pour la rétablir."
|
||||
},
|
||||
"camoufoxDeprecation": {
|
||||
"title": "La prise en charge de Camoufox prend fin",
|
||||
"description": "La prise en charge des profils Camoufox prendra fin le 8 juillet 2026. Vous avez un ou plusieurs profils Camoufox. Migrez-les vers Wayfern avant cette date — après quoi, les profils Camoufox pourraient cesser de fonctionner.",
|
||||
"acknowledge": "Compris"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,11 @@
|
||||
"extDefault": "既定",
|
||||
"dnsLevel": "DNS ブロックリスト: {{level}}",
|
||||
"extSearch": "グループを検索…",
|
||||
"extEmpty": "拡張機能グループがありません"
|
||||
"extEmpty": "拡張機能グループがありません",
|
||||
"cantModifyRunning": "実行中のプロファイルは変更できません",
|
||||
"cantModifyLaunching": "起動中はプロファイルを変更できません",
|
||||
"cantModifyStopping": "停止中はプロファイルを変更できません",
|
||||
"cantModifyUpdating": "ブラウザの更新中はプロファイルを変更できません"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "起動",
|
||||
@@ -639,7 +643,11 @@
|
||||
"profileSynced": "プロファイル '{{name}}' を同期しました",
|
||||
"profileSyncFailed": "プロファイル '{{name}}' の同期に失敗しました",
|
||||
"profileSyncFailedWithError": "プロファイル '{{name}}' の同期に失敗しました: {{error}}"
|
||||
}
|
||||
},
|
||||
"enabledToast": "同期を有効化しました",
|
||||
"disabledToast": "同期を無効化しました",
|
||||
"enabledDescription": "プロファイルの同期が有効になりました",
|
||||
"disabledDescription": "プロファイルの同期が無効になりました"
|
||||
},
|
||||
"integrations": {
|
||||
"title": "統合",
|
||||
@@ -918,6 +926,11 @@
|
||||
"syncingProfile": "プロファイル '{{name}}' を同期中...",
|
||||
"syncingProfileWithProgress": "{{count}} ファイル ({{size}})",
|
||||
"updatingVersions": "ブラウザバージョンを更新中..."
|
||||
},
|
||||
"progress": {
|
||||
"remaining": "残り {{time}}",
|
||||
"filesProgress": "{{completed}}/{{total}} ファイル",
|
||||
"filesFailed": "{{count}} 件のファイルが失敗しました"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
@@ -1136,7 +1149,9 @@
|
||||
"addRule": "ルールを追加",
|
||||
"rulePlaceholder": "例: example.com, 192.168.1.*, .*\\.local",
|
||||
"noRules": "バイパスルールは設定されていません。",
|
||||
"ruleTypes": "ホスト名、IPアドレス、正規表現パターンをサポートしています。"
|
||||
"ruleTypes": "ホスト名、IPアドレス、正規表現パターンをサポートしています。",
|
||||
"vpnLabel": "VPN: {{name}}",
|
||||
"proxyLabel": "Proxy: {{name}}"
|
||||
},
|
||||
"launchHook": {
|
||||
"title": "起動フックURL",
|
||||
@@ -1196,8 +1211,14 @@
|
||||
},
|
||||
"fingerprint": {
|
||||
"notSupported": "フィンガープリント編集は Camoufox / Wayfern プロファイルでのみ利用できます。",
|
||||
"lockedTitle": "フィンガープリントは Pro 機能です",
|
||||
"lockedDescription": "プロファイルのフィンガープリントの表示と編集には有効な有料プランが必要です。アップグレードしてフィンガープリント保護をご利用ください。"
|
||||
"lockedTitle": "フィンガープリントの表示と編集は Pro 機能です",
|
||||
"lockedDescription": "フィンガープリント保護はすべてのプランに含まれています。プロファイルのフィンガープリントの値を表示・編集するには、有効な有料プランが必要です。"
|
||||
},
|
||||
"syncStatusValue": {
|
||||
"waiting": "待機中",
|
||||
"syncing": "同期中",
|
||||
"synced": "同期済み",
|
||||
"error": "エラー"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1718,7 +1739,8 @@
|
||||
"updateStartedDescription": "バージョン {{version}} のダウンロードがまもなく開始されます。更新が完了するまでブラウザの起動は無効になります。",
|
||||
"downloadStarting": "{{browser}} {{version}} のダウンロードを開始しています",
|
||||
"downloadProgressBelow": "ダウンロードの進行状況は下に表示されます...",
|
||||
"autoDownloadStarted": "{{browser}} {{version}} を自動的にダウンロードしています。進行状況は下に表示されます。"
|
||||
"autoDownloadStarted": "{{browser}} {{version}} を自動的にダウンロードしています。進行状況は下に表示されます。",
|
||||
"lookingForUpdates": "{{browser}} の更新を確認しています"
|
||||
}
|
||||
},
|
||||
"profilePassword": {
|
||||
@@ -1807,10 +1829,11 @@
|
||||
"cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。",
|
||||
"cookieDbUnavailable": "Cookie を読み取れません — Cookie ストアを利用できません。",
|
||||
"selfHostedRequiresLogout": "セルフホストサーバーを設定する前に Donut アカウントからサインアウトしてください。",
|
||||
"fingerprintRequiresPro": "フィンガープリント保護には有効な有料プランが必要です。",
|
||||
"fingerprintRequiresPro": "フィンガープリントの表示または編集には有効な有料プランが必要です。保護機能はすべてのプランに含まれています。",
|
||||
"proxyNotWorking": "選択したプロキシが機能していないため、プロファイルは作成されませんでした。",
|
||||
"proxyPaymentRequired": "選択したプロキシは支払いが必要です(402)。サブスクリプションが期限切れの可能性があります。そのため、プロファイルは作成されませんでした。",
|
||||
"vpnNotWorking": "選択したVPNが機能していないため、プロファイルは作成されませんでした。"
|
||||
"vpnNotWorking": "選択したVPNが機能していないため、プロファイルは作成されませんでした。",
|
||||
"camoufoxImportDeprecated": "Firefox ベース(Camoufox)のプロファイルのインポートはサポートされなくなりました。Camoufox は廃止予定です。代わりに Wayfern をご利用ください。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "プロファイル",
|
||||
@@ -1939,7 +1962,7 @@
|
||||
},
|
||||
"browserSupport": {
|
||||
"endingSoonTitle": "ブラウザのサポートが間もなく終了します",
|
||||
"endingSoonDescription": "次のプロファイルのサポートは 2026 年 3 月 15 日に削除されます: {{profiles}}。Wayfern または Camoufox のプロファイルに移行してください。"
|
||||
"endingSoonDescription": "以下のプロファイルのサポートは 2026 年 3 月 15 日に削除されます: {{profiles}}。Wayfern プロファイルに移行してください。"
|
||||
},
|
||||
"onboarding": {
|
||||
"steps": {
|
||||
@@ -2017,5 +2040,14 @@
|
||||
"trialBadge": "2週間無料",
|
||||
"commercialDesc": "2週間の評価期間は無料です。その後は有料プランが必要で、これによりプロジェクトの維持と発展が支えられます。"
|
||||
}
|
||||
},
|
||||
"wayfernBlocked": {
|
||||
"title": "ブラウザの自動化が一時停止しました",
|
||||
"description": "通常は複数のデバイスで同時にサインインしたことが原因で、アカウントのProブラウザ機能が一時的に制限されました。他のデバイスからサインアウトし、プロファイルを再起動すると復元されます。"
|
||||
},
|
||||
"camoufoxDeprecation": {
|
||||
"title": "Camoufox のサポートが終了します",
|
||||
"description": "Camoufox プロファイルのサポートは 2026 年 7 月 8 日に終了します。Camoufox プロファイルが 1 つ以上あります。それまでに Wayfern へ移行してください。その後、Camoufox プロファイルは動作しなくなる可能性があります。",
|
||||
"acknowledge": "了解しました"
|
||||
}
|
||||
}
|
||||
|
||||
+58
-26
@@ -131,12 +131,12 @@
|
||||
"title": "기본 브라우저",
|
||||
"setAsDefault": "기본 브라우저로 설정",
|
||||
"alreadyDefault": "이미 기본 브라우저입니다",
|
||||
"description": "기본 브라우저로 설정하면 도넛 브라우저가 웹 링크를 처리하고 사용할 프로필을 선택할 수 있습니다."
|
||||
"description": "기본 브라우저로 설정하면 Donut Browser가 웹 링크를 처리하고 사용할 프로필을 선택할 수 있습니다."
|
||||
},
|
||||
"permissions": {
|
||||
"title": "시스템 권한",
|
||||
"loading": "권한 불러오는 중...",
|
||||
"description": "이 권한은 도넛 브라우저에서 실행된 브라우저가 시스템 리소스에 액세스할 수 있도록 합니다. 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.",
|
||||
"description": "이 권한은 Donut Browser에서 실행된 브라우저가 시스템 리소스에 액세스할 수 있도록 합니다. 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.",
|
||||
"microphone": "마이크",
|
||||
"microphoneDescription": "브라우저 애플리케이션의 마이크 액세스",
|
||||
"camera": "카메라",
|
||||
@@ -180,7 +180,7 @@
|
||||
"trialExpired": "체험판이 만료되었습니다",
|
||||
"trialExpiredDescription": "개인 사용은 무료로 유지됩니다. 상업적 사용에는 라이선스가 필요합니다.",
|
||||
"subscriptionActive": "구독 중 — {{plan}} 플랜",
|
||||
"subscriptionActiveDescription": "도넛 브라우저 구독이 활성 상태입니다. 플랜 기간 동안 상업적 사용이 라이선스됩니다."
|
||||
"subscriptionActiveDescription": "Donut Browser 구독이 활성 상태입니다. 플랜 기간 동안 상업적 사용이 라이선스됩니다."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "고급",
|
||||
@@ -193,7 +193,7 @@
|
||||
"copyLogsDescription": "최신 로그 파일(최대 5MB)을 클립보드에 묶어 버그 보고서에서 공유할 수 있도록 합니다."
|
||||
},
|
||||
"disableAutoUpdates": "앱 자동 업데이트 사용 안 함",
|
||||
"disableAutoUpdatesDescription": "도넛 브라우저 업데이트를 앱이 자동으로 확인하고 설치하지 않도록 합니다. 브라우저 업데이트는 영향을 받지 않습니다.",
|
||||
"disableAutoUpdatesDescription": "Donut Browser 업데이트를 앱이 자동으로 확인하고 설치하지 않도록 합니다. 브라우저 업데이트는 영향을 받지 않습니다.",
|
||||
"keepDecryptedProfilesInRam": "복호화된 프로필을 RAM에 유지",
|
||||
"keepDecryptedProfilesInRamDescription": "비밀번호로 보호된 프로필의 복호화된 RAM 사본을 실행 사이에 유지하여 시작 속도를 높입니다. 디스크의 사본은 그대로 암호화된 상태로 유지됩니다."
|
||||
},
|
||||
@@ -212,7 +212,7 @@
|
||||
"extensions": "확장 프로그램"
|
||||
},
|
||||
"newProfile": "새로 만들기",
|
||||
"donutLogo": "도넛 브라우저 로고",
|
||||
"donutLogo": "Donut Browser 로고",
|
||||
"scrollGroupsLeft": "그룹 왼쪽으로 스크롤",
|
||||
"scrollGroupsRight": "그룹 오른쪽으로 스크롤"
|
||||
},
|
||||
@@ -239,7 +239,11 @@
|
||||
"extDefault": "기본값",
|
||||
"dnsLevel": "DNS 차단 목록: {{level}}",
|
||||
"extSearch": "그룹 검색…",
|
||||
"extEmpty": "확장 프로그램 그룹이 없습니다"
|
||||
"extEmpty": "확장 프로그램 그룹이 없습니다",
|
||||
"cantModifyRunning": "실행 중인 프로필은 수정할 수 없습니다",
|
||||
"cantModifyLaunching": "실행하는 동안 프로필을 수정할 수 없습니다",
|
||||
"cantModifyStopping": "중지하는 동안 프로필을 수정할 수 없습니다",
|
||||
"cantModifyUpdating": "브라우저 업데이트 중에는 프로필을 수정할 수 없습니다"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "실행",
|
||||
@@ -639,7 +643,11 @@
|
||||
"profileSynced": "프로필 '{{name}}'이(가) 동기화되었습니다",
|
||||
"profileSyncFailed": "프로필 '{{name}}' 동기화 실패",
|
||||
"profileSyncFailedWithError": "프로필 '{{name}}' 동기화 실패: {{error}}"
|
||||
}
|
||||
},
|
||||
"enabledToast": "동기화 사용",
|
||||
"disabledToast": "동기화 사용 안 함",
|
||||
"enabledDescription": "프로필 동기화가 활성화되었습니다",
|
||||
"disabledDescription": "프로필 동기화가 비활성화되었습니다"
|
||||
},
|
||||
"integrations": {
|
||||
"title": "통합",
|
||||
@@ -918,6 +926,11 @@
|
||||
"syncingProfile": "프로필 '{{name}}' 동기화 중...",
|
||||
"syncingProfileWithProgress": "{{count}}개 파일 ({{size}})",
|
||||
"updatingVersions": "브라우저 버전 업데이트 중..."
|
||||
},
|
||||
"progress": {
|
||||
"remaining": "{{time}} 남음",
|
||||
"filesProgress": "{{completed}}/{{total}} 파일",
|
||||
"filesFailed": "{{count}}개 파일 실패"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
@@ -1136,12 +1149,14 @@
|
||||
"addRule": "규칙 추가",
|
||||
"rulePlaceholder": "예: example.com, 192.168.1.*, .*\\.local",
|
||||
"noRules": "구성된 우회 규칙이 없습니다.",
|
||||
"ruleTypes": "호스트 이름, IP 주소 및 정규식 패턴을 지원합니다."
|
||||
"ruleTypes": "호스트 이름, IP 주소 및 정규식 패턴을 지원합니다.",
|
||||
"vpnLabel": "VPN: {{name}}",
|
||||
"proxyLabel": "Proxy: {{name}}"
|
||||
},
|
||||
"launchHook": {
|
||||
"title": "실행 후크 URL",
|
||||
"label": "실행 후크 URL",
|
||||
"description": "도넛 브라우저는 프로필이 실행될 때마다 이 URL로 GET 요청을 보냅니다.",
|
||||
"description": "Donut Browser는 프로필이 실행될 때마다 이 URL로 GET 요청을 보냅니다.",
|
||||
"placeholder": "https://example.com/hooks/profile-launch",
|
||||
"invalidUrlHint": "유효한 http:// 또는 https:// URL을 입력하세요."
|
||||
},
|
||||
@@ -1196,8 +1211,14 @@
|
||||
},
|
||||
"fingerprint": {
|
||||
"notSupported": "핑거프린트 편집은 Camoufox 및 Wayfern 프로필에서만 사용할 수 있습니다.",
|
||||
"lockedTitle": "핑거프린트는 Pro 기능입니다",
|
||||
"lockedDescription": "프로필의 핑거프린트를 보고 편집하려면 활성 유료 요금제가 필요합니다. 업그레이드하여 핑거프린트 보호를 잠금 해제하세요."
|
||||
"lockedTitle": "핑거프린트 보기 및 편집은 Pro 기능입니다",
|
||||
"lockedDescription": "핑거프린트 보호는 모든 요금제에 포함되어 있습니다. 프로필의 핑거프린트 값을 보고 편집하려면 활성 유료 요금제가 필요합니다."
|
||||
},
|
||||
"syncStatusValue": {
|
||||
"waiting": "대기 중",
|
||||
"syncing": "동기화 중",
|
||||
"synced": "동기화됨",
|
||||
"error": "오류"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1535,7 +1556,7 @@
|
||||
},
|
||||
"wayfernTerms": {
|
||||
"title": "Wayfern 이용 약관",
|
||||
"description": "도넛 브라우저를 사용하기 전에 Wayfern의 이용 약관을 읽고 동의해야 합니다.",
|
||||
"description": "Donut Browser를 사용하기 전에 Wayfern의 이용 약관을 읽고 동의해야 합니다.",
|
||||
"reviewLabel": "다음 위치에서 이용 약관을 검토하세요:",
|
||||
"agreeNotice": "\"동의함\"을 클릭하면 이 약관에 동의하는 것입니다.",
|
||||
"acceptButton": "동의함",
|
||||
@@ -1546,7 +1567,7 @@
|
||||
"commercialTrial": {
|
||||
"title": "상업용 체험판 만료됨",
|
||||
"description": "2주 상업용 체험판 기간이 종료되었습니다.",
|
||||
"body": "도넛 브라우저를 비즈니스 용도로 사용하는 경우 계속 사용하려면 상업용 라이선스를 구매해야 합니다. 개인 용도로는 계속 무료로 사용할 수 있습니다.",
|
||||
"body": "Donut Browser를 비즈니스 용도로 사용하는 경우 계속 사용하려면 상업용 라이선스를 구매해야 합니다. 개인 용도로는 계속 무료로 사용할 수 있습니다.",
|
||||
"understandButton": "이해했습니다",
|
||||
"failed": "확인 저장 실패",
|
||||
"tryAgain": "다시 시도하세요"
|
||||
@@ -1554,10 +1575,10 @@
|
||||
"permissionDialog": {
|
||||
"titleMicrophone": "마이크 액세스가 필요합니다",
|
||||
"titleCamera": "카메라 액세스가 필요합니다",
|
||||
"descMicrophone": "도넛 브라우저는 웹 브라우저에서 마이크 기능을 활성화하기 위해 마이크에 액세스해야 합니다. 마이크를 사용하려는 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.",
|
||||
"descCamera": "도넛 브라우저는 웹 브라우저에서 카메라 기능을 활성화하기 위해 카메라에 액세스해야 합니다. 카메라를 사용하려는 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.",
|
||||
"grantedMicrophone": "권한이 허용되었습니다! 이제 도넛 브라우저에서 실행된 브라우저가 마이크에 액세스할 수 있습니다.",
|
||||
"grantedCamera": "권한이 허용되었습니다! 이제 도넛 브라우저에서 실행된 브라우저가 카메라에 액세스할 수 있습니다.",
|
||||
"descMicrophone": "Donut Browser는 웹 브라우저에서 마이크 기능을 활성화하기 위해 마이크에 액세스해야 합니다. 마이크를 사용하려는 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.",
|
||||
"descCamera": "Donut Browser는 웹 브라우저에서 카메라 기능을 활성화하기 위해 카메라에 액세스해야 합니다. 카메라를 사용하려는 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.",
|
||||
"grantedMicrophone": "권한이 허용되었습니다! 이제 Donut Browser에서 실행된 브라우저가 마이크에 액세스할 수 있습니다.",
|
||||
"grantedCamera": "권한이 허용되었습니다! 이제 Donut Browser에서 실행된 브라우저가 카메라에 액세스할 수 있습니다.",
|
||||
"notGrantedMicrophone": "권한이 허용되지 않았습니다. 아래 버튼을 클릭하여 마이크에 대한 액세스를 요청하세요.",
|
||||
"notGrantedCamera": "권한이 허용되지 않았습니다. 아래 버튼을 클릭하여 카메라에 대한 액세스를 요청하세요.",
|
||||
"doneButton": "완료",
|
||||
@@ -1670,7 +1691,7 @@
|
||||
},
|
||||
"appUpdate": {
|
||||
"toast": {
|
||||
"updateFailed": "도넛 브라우저 업데이트 실패",
|
||||
"updateFailed": "Donut Browser 업데이트 실패",
|
||||
"restartFailed": "재시작 실패",
|
||||
"updateReady": "업데이트 준비됨, 적용하려면 재시작하세요",
|
||||
"manualDownloadRequired": "수동 다운로드 필요",
|
||||
@@ -1718,7 +1739,8 @@
|
||||
"updateStartedDescription": "버전 {{version}} 다운로드가 곧 시작됩니다. 업데이트가 완료될 때까지 브라우저 실행이 비활성화됩니다.",
|
||||
"downloadStarting": "{{browser}} {{version}} 다운로드를 시작하는 중",
|
||||
"downloadProgressBelow": "다운로드 진행 상황이 아래에 표시됩니다...",
|
||||
"autoDownloadStarted": "{{browser}} {{version}}을(를) 자동으로 다운로드하는 중입니다. 진행 상황이 아래에 표시됩니다."
|
||||
"autoDownloadStarted": "{{browser}} {{version}}을(를) 자동으로 다운로드하는 중입니다. 진행 상황이 아래에 표시됩니다.",
|
||||
"lookingForUpdates": "{{browser}} 업데이트를 확인하는 중"
|
||||
}
|
||||
},
|
||||
"profilePassword": {
|
||||
@@ -1762,7 +1784,7 @@
|
||||
},
|
||||
"warnings": {
|
||||
"forgetWarningTitle": "중요: 이 비밀번호는 복구할 수 없습니다",
|
||||
"forgetWarningBody": "도넛 브라우저는 이 비밀번호를 재설정, 복구 또는 우회할 수 없습니다. 잊어버리면 이 프로필의 데이터에 영구적으로 액세스할 수 없게 됩니다."
|
||||
"forgetWarningBody": "Donut Browser는 이 비밀번호를 재설정, 복구 또는 우회할 수 없습니다. 잊어버리면 이 프로필의 데이터에 영구적으로 액세스할 수 없게 됩니다."
|
||||
},
|
||||
"modes": {
|
||||
"set": "설정",
|
||||
@@ -1806,11 +1828,12 @@
|
||||
"invalidLaunchHookUrl": "잘못된 실행 후크 URL입니다. 전체 http:// 또는 https:// URL을 사용하세요.",
|
||||
"cookieDbLocked": "쿠키를 읽을 수 없습니다 — 데이터베이스가 잠겨 있습니다. 브라우저를 닫고 다시 시도하세요.",
|
||||
"cookieDbUnavailable": "쿠키를 읽을 수 없습니다 — 쿠키 저장소를 사용할 수 없습니다.",
|
||||
"selfHostedRequiresLogout": "자체 호스팅 서버를 구성하기 전에 도넛 계정에서 로그아웃하세요.",
|
||||
"fingerprintRequiresPro": "핑거프린트 보호에는 활성 유료 요금제가 필요합니다.",
|
||||
"selfHostedRequiresLogout": "자체 호스팅 서버를 구성하기 전에 Donut 계정에서 로그아웃하세요.",
|
||||
"fingerprintRequiresPro": "핑거프린트를 보거나 편집하려면 활성 유료 요금제가 필요합니다. 보호 기능은 모든 요금제에 포함되어 있습니다.",
|
||||
"proxyNotWorking": "선택한 프록시가 작동하지 않아 프로필이 생성되지 않았습니다.",
|
||||
"proxyPaymentRequired": "선택한 프록시는 결제가 필요합니다(402). 구독이 만료되었을 수 있어 프로필이 생성되지 않았습니다.",
|
||||
"vpnNotWorking": "선택한 VPN이 작동하지 않아 프로필이 생성되지 않았습니다."
|
||||
"vpnNotWorking": "선택한 VPN이 작동하지 않아 프로필이 생성되지 않았습니다.",
|
||||
"camoufoxImportDeprecated": "Firefox 기반(Camoufox) 프로필 가져오기는 더 이상 지원되지 않습니다. Camoufox는 지원 종료 예정입니다. 대신 Wayfern을 사용하세요."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "프로필",
|
||||
@@ -1885,8 +1908,8 @@
|
||||
},
|
||||
"selfHosted": {
|
||||
"title": "자체 호스팅 동기화 서버",
|
||||
"description": "호스팅 클라우드를 사용하지 않고 프로필, 프록시, 그룹 및 확장 프로그램을 동기화하려면 도넛을 자체 donut-sync 서버로 연결하세요.",
|
||||
"disabledWhileLoggedIn": "도넛 계정에 로그인되어 있는 동안에는 자체 호스팅 동기화를 사용할 수 없습니다. 사용자 지정 서버를 사용하려면 로그아웃하세요.",
|
||||
"description": "호스팅 클라우드를 사용하지 않고 프로필, 프록시, 그룹 및 확장 프로그램을 동기화하려면 Donut을 자체 donut-sync 서버로 연결하세요.",
|
||||
"disabledWhileLoggedIn": "Donut 계정에 로그인되어 있는 동안에는 자체 호스팅 동기화를 사용할 수 없습니다. 사용자 지정 서버를 사용하려면 로그아웃하세요.",
|
||||
"connectionStatus": "연결:",
|
||||
"statusUnknown": "테스트 안 됨",
|
||||
"testConnection": "연결 테스트",
|
||||
@@ -1939,7 +1962,7 @@
|
||||
},
|
||||
"browserSupport": {
|
||||
"endingSoonTitle": "브라우저 지원이 곧 종료됩니다",
|
||||
"endingSoonDescription": "다음 프로필에 대한 지원이 2026년 3월 15일에 제거됩니다: {{profiles}}. Wayfern 또는 Camoufox 프로필로 마이그레이션하세요."
|
||||
"endingSoonDescription": "다음 프로필에 대한 지원이 2026년 3월 15일에 제거됩니다: {{profiles}}. Wayfern 프로필로 이전하세요."
|
||||
},
|
||||
"onboarding": {
|
||||
"steps": {
|
||||
@@ -2017,5 +2040,14 @@
|
||||
"trialBadge": "2주 무료",
|
||||
"commercialDesc": "2주간의 평가 기간 동안 무료입니다. 이후에는 유료 요금제가 필요하며, 이를 통해 프로젝트가 유지되고 발전할 수 있습니다."
|
||||
}
|
||||
},
|
||||
"wayfernBlocked": {
|
||||
"title": "브라우저 자동화가 일시 중지됨",
|
||||
"description": "보통 여러 기기에서 동시에 로그인하여 계정의 Pro 브라우저 기능이 일시적으로 제한되었습니다. 다른 기기에서 로그아웃한 후 프로필을 다시 실행하면 복원됩니다."
|
||||
},
|
||||
"camoufoxDeprecation": {
|
||||
"title": "Camoufox 지원이 종료됩니다",
|
||||
"description": "Camoufox 프로필 지원이 2026년 7월 8일에 종료됩니다. Camoufox 프로필이 하나 이상 있습니다. 그 전에 Wayfern으로 이전하세요. 이후에는 Camoufox 프로필이 작동하지 않을 수 있습니다.",
|
||||
"acknowledge": "확인"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,11 @@
|
||||
"extDefault": "Padrão",
|
||||
"dnsLevel": "Lista DNS: {{level}}",
|
||||
"extSearch": "Pesquisar grupos…",
|
||||
"extEmpty": "Sem grupos de extensões"
|
||||
"extEmpty": "Sem grupos de extensões",
|
||||
"cantModifyRunning": "Não é possível modificar um perfil em execução",
|
||||
"cantModifyLaunching": "Não é possível modificar o perfil durante a inicialização",
|
||||
"cantModifyStopping": "Não é possível modificar o perfil durante a parada",
|
||||
"cantModifyUpdating": "Não é possível modificar o perfil enquanto o navegador é atualizado"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Iniciar",
|
||||
@@ -639,7 +643,11 @@
|
||||
"profileSynced": "Perfil '{{name}}' sincronizado com sucesso",
|
||||
"profileSyncFailed": "Falha ao sincronizar o perfil '{{name}}'",
|
||||
"profileSyncFailedWithError": "Falha ao sincronizar o perfil '{{name}}': {{error}}"
|
||||
}
|
||||
},
|
||||
"enabledToast": "Sincronização ativada",
|
||||
"disabledToast": "Sincronização desativada",
|
||||
"enabledDescription": "A sincronização do perfil foi ativada",
|
||||
"disabledDescription": "A sincronização do perfil foi desativada"
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrações",
|
||||
@@ -918,6 +926,11 @@
|
||||
"syncingProfile": "Sincronizando perfil '{{name}}'...",
|
||||
"syncingProfileWithProgress": "{{count}} arquivos ({{size}})",
|
||||
"updatingVersions": "Atualizando versões de navegadores..."
|
||||
},
|
||||
"progress": {
|
||||
"remaining": "{{time}} restante",
|
||||
"filesProgress": "{{completed}}/{{total}} arquivos",
|
||||
"filesFailed": "{{count}} arquivo(s) com falha"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
@@ -1136,7 +1149,9 @@
|
||||
"addRule": "Adicionar Regra",
|
||||
"rulePlaceholder": "ex. example.com, 192.168.1.*, .*\\.local",
|
||||
"noRules": "Nenhuma regra de bypass configurada.",
|
||||
"ruleTypes": "Suporta nomes de host, endereços IP e padrões regex."
|
||||
"ruleTypes": "Suporta nomes de host, endereços IP e padrões regex.",
|
||||
"vpnLabel": "VPN: {{name}}",
|
||||
"proxyLabel": "Proxy: {{name}}"
|
||||
},
|
||||
"launchHook": {
|
||||
"title": "URL do hook de inicialização",
|
||||
@@ -1196,8 +1211,14 @@
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"syncStatusValue": {
|
||||
"waiting": "Aguardando",
|
||||
"syncing": "Sincronizando",
|
||||
"synced": "Sincronizado",
|
||||
"error": "Erro"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1718,7 +1739,8 @@
|
||||
"updateStartedDescription": "O download da versão {{version}} começará em breve. O início do navegador está desativado até a atualização ser concluída.",
|
||||
"downloadStarting": "Iniciando o download do {{browser}} {{version}}",
|
||||
"downloadProgressBelow": "O progresso do download será mostrado abaixo...",
|
||||
"autoDownloadStarted": "Baixando {{browser}} {{version}} automaticamente. O progresso será mostrado abaixo."
|
||||
"autoDownloadStarted": "Baixando {{browser}} {{version}} automaticamente. O progresso será mostrado abaixo.",
|
||||
"lookingForUpdates": "Procurando atualizações para {{browser}}"
|
||||
}
|
||||
},
|
||||
"profilePassword": {
|
||||
@@ -1807,10 +1829,11 @@
|
||||
"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."
|
||||
"vpnNotWorking": "A VPN selecionada não está funcionando, então o perfil não foi criado.",
|
||||
"camoufoxImportDeprecated": "A importação de perfis baseados em Firefox (Camoufox) não é mais suportada. O Camoufox está sendo descontinuado — use o Wayfern."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfis",
|
||||
@@ -1939,7 +1962,7 @@
|
||||
},
|
||||
"browserSupport": {
|
||||
"endingSoonTitle": "O suporte ao navegador terminará em breve",
|
||||
"endingSoonDescription": "O suporte aos seguintes perfis será removido em 15 de março de 2026: {{profiles}}. Migre para perfis Wayfern ou Camoufox."
|
||||
"endingSoonDescription": "O suporte aos seguintes perfis será removido em 15 de março de 2026: {{profiles}}. Migre para perfis Wayfern."
|
||||
},
|
||||
"onboarding": {
|
||||
"steps": {
|
||||
@@ -2017,5 +2040,14 @@
|
||||
"trialBadge": "2 semanas grátis",
|
||||
"commercialDesc": "Gratuito durante uma avaliação de 2 semanas. Depois, um plano pago mantém o projeto ativo e próspero."
|
||||
}
|
||||
},
|
||||
"wayfernBlocked": {
|
||||
"title": "Automação do navegador pausada",
|
||||
"description": "Sua conta foi temporariamente restringida dos recursos Pro do navegador, geralmente por entrar em vários dispositivos ao mesmo tempo. Saia dos outros dispositivos e reinicie o perfil para restaurá-la."
|
||||
},
|
||||
"camoufoxDeprecation": {
|
||||
"title": "O suporte ao Camoufox está terminando",
|
||||
"description": "O suporte aos perfis do Camoufox terminará em 8 de julho de 2026. Você tem um ou mais perfis do Camoufox. Migre-os para o Wayfern antes dessa data — depois disso, os perfis do Camoufox podem parar de funcionar.",
|
||||
"acknowledge": "Entendi"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,11 @@
|
||||
"extDefault": "По умолч.",
|
||||
"dnsLevel": "DNS-блок-лист: {{level}}",
|
||||
"extSearch": "Поиск групп…",
|
||||
"extEmpty": "Нет групп расширений"
|
||||
"extEmpty": "Нет групп расширений",
|
||||
"cantModifyRunning": "Нельзя изменить запущенный профиль",
|
||||
"cantModifyLaunching": "Нельзя изменить профиль во время запуска",
|
||||
"cantModifyStopping": "Нельзя изменить профиль во время остановки",
|
||||
"cantModifyUpdating": "Нельзя изменить профиль во время обновления браузера"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Запустить",
|
||||
@@ -639,7 +643,11 @@
|
||||
"profileSynced": "Профиль '{{name}}' успешно синхронизирован",
|
||||
"profileSyncFailed": "Не удалось синхронизировать профиль '{{name}}'",
|
||||
"profileSyncFailedWithError": "Не удалось синхронизировать профиль '{{name}}': {{error}}"
|
||||
}
|
||||
},
|
||||
"enabledToast": "Синхронизация включена",
|
||||
"disabledToast": "Синхронизация отключена",
|
||||
"enabledDescription": "Синхронизация профиля включена",
|
||||
"disabledDescription": "Синхронизация профиля отключена"
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Интеграции",
|
||||
@@ -918,6 +926,11 @@
|
||||
"syncingProfile": "Синхронизация профиля '{{name}}'...",
|
||||
"syncingProfileWithProgress": "{{count}} файлов ({{size}})",
|
||||
"updatingVersions": "Обновление версий браузеров..."
|
||||
},
|
||||
"progress": {
|
||||
"remaining": "осталось {{time}}",
|
||||
"filesProgress": "{{completed}}/{{total}} файлов",
|
||||
"filesFailed": "Ошибка в {{count}} файле(ах)"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
@@ -1136,7 +1149,9 @@
|
||||
"addRule": "Добавить правило",
|
||||
"rulePlaceholder": "напр. example.com, 192.168.1.*, .*\\.local",
|
||||
"noRules": "Правила обхода не настроены.",
|
||||
"ruleTypes": "Поддерживает имена хостов, IP-адреса и шаблоны регулярных выражений."
|
||||
"ruleTypes": "Поддерживает имена хостов, IP-адреса и шаблоны регулярных выражений.",
|
||||
"vpnLabel": "VPN: {{name}}",
|
||||
"proxyLabel": "Proxy: {{name}}"
|
||||
},
|
||||
"launchHook": {
|
||||
"title": "URL хука запуска",
|
||||
@@ -1196,8 +1211,14 @@
|
||||
},
|
||||
"fingerprint": {
|
||||
"notSupported": "Редактирование отпечатков доступно только для профилей Camoufox и Wayfern.",
|
||||
"lockedTitle": "Отпечаток — функция Pro",
|
||||
"lockedDescription": "Для просмотра и редактирования отпечатка профиля требуется активный платный план. Оформите подписку, чтобы разблокировать защиту от отпечатков."
|
||||
"lockedTitle": "Просмотр и редактирование отпечатка — функция Pro",
|
||||
"lockedDescription": "Защита от отпечатков включена во все планы. Активный платный план требуется именно для просмотра и редактирования значений отпечатка профиля."
|
||||
},
|
||||
"syncStatusValue": {
|
||||
"waiting": "Ожидание",
|
||||
"syncing": "Синхронизация",
|
||||
"synced": "Синхронизировано",
|
||||
"error": "Ошибка"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1718,7 +1739,8 @@
|
||||
"updateStartedDescription": "Загрузка версии {{version}} скоро начнётся. Запуск браузера отключён до завершения обновления.",
|
||||
"downloadStarting": "Запуск загрузки {{browser}} {{version}}",
|
||||
"downloadProgressBelow": "Прогресс загрузки будет показан ниже...",
|
||||
"autoDownloadStarted": "Автоматическая загрузка {{browser}} {{version}}. Прогресс будет показан ниже."
|
||||
"autoDownloadStarted": "Автоматическая загрузка {{browser}} {{version}}. Прогресс будет показан ниже.",
|
||||
"lookingForUpdates": "Поиск обновлений для {{browser}}"
|
||||
}
|
||||
},
|
||||
"profilePassword": {
|
||||
@@ -1807,10 +1829,11 @@
|
||||
"cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.",
|
||||
"cookieDbUnavailable": "Не удалось прочитать куки — хранилище куки недоступно.",
|
||||
"selfHostedRequiresLogout": "Выйдите из аккаунта Donut, прежде чем настраивать собственный сервер.",
|
||||
"fingerprintRequiresPro": "Для защиты от отпечатков требуется активный платный план.",
|
||||
"fingerprintRequiresPro": "Для просмотра или редактирования отпечатка требуется активный платный план. Защита включена во все планы.",
|
||||
"proxyNotWorking": "Выбранный прокси не работает, поэтому профиль не создан.",
|
||||
"proxyPaymentRequired": "Выбранный прокси требует оплаты (402) — возможно, его подписка истекла — поэтому профиль не создан.",
|
||||
"vpnNotWorking": "Выбранный VPN не работает, поэтому профиль не создан."
|
||||
"vpnNotWorking": "Выбранный VPN не работает, поэтому профиль не создан.",
|
||||
"camoufoxImportDeprecated": "Импорт профилей на основе Firefox (Camoufox) больше не поддерживается. Camoufox выводится из эксплуатации — используйте Wayfern."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Профили",
|
||||
@@ -1939,7 +1962,7 @@
|
||||
},
|
||||
"browserSupport": {
|
||||
"endingSoonTitle": "Поддержка браузера скоро завершится",
|
||||
"endingSoonDescription": "Поддержка следующих профилей будет прекращена 15 марта 2026 г.: {{profiles}}. Перейдите на профили Wayfern или Camoufox."
|
||||
"endingSoonDescription": "Поддержка следующих профилей будет прекращена 15 марта 2026 года: {{profiles}}. Перейдите на профили Wayfern."
|
||||
},
|
||||
"onboarding": {
|
||||
"steps": {
|
||||
@@ -2017,5 +2040,14 @@
|
||||
"trialBadge": "2 недели бесплатно",
|
||||
"commercialDesc": "Бесплатно в течение 2-недельного ознакомительного периода. После этого требуется платный план, что помогает поддерживать и развивать проект."
|
||||
}
|
||||
},
|
||||
"wayfernBlocked": {
|
||||
"title": "Автоматизация браузера приостановлена",
|
||||
"description": "Доступ вашей учётной записи к Pro-функциям браузера временно ограничен — обычно из-за входа сразу на нескольких устройствах. Выйдите из аккаунта на других устройствах и перезапустите профиль, чтобы восстановить доступ."
|
||||
},
|
||||
"camoufoxDeprecation": {
|
||||
"title": "Поддержка Camoufox прекращается",
|
||||
"description": "Поддержка профилей Camoufox прекращается 8 июля 2026 года. У вас есть один или несколько профилей Camoufox. Перенесите их на Wayfern до этой даты — после неё профили Camoufox могут перестать работать.",
|
||||
"acknowledge": "Понятно"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -239,7 +239,11 @@
|
||||
"extDefault": "默认",
|
||||
"dnsLevel": "DNS 屏蔽列表: {{level}}",
|
||||
"extSearch": "搜索分组…",
|
||||
"extEmpty": "没有扩展组"
|
||||
"extEmpty": "没有扩展组",
|
||||
"cantModifyRunning": "无法修改正在运行的配置文件",
|
||||
"cantModifyLaunching": "启动期间无法修改配置文件",
|
||||
"cantModifyStopping": "停止期间无法修改配置文件",
|
||||
"cantModifyUpdating": "浏览器更新期间无法修改配置文件"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "启动",
|
||||
@@ -639,7 +643,11 @@
|
||||
"profileSynced": "配置文件 '{{name}}' 同步成功",
|
||||
"profileSyncFailed": "同步配置文件 '{{name}}' 失败",
|
||||
"profileSyncFailedWithError": "同步配置文件 '{{name}}' 失败: {{error}}"
|
||||
}
|
||||
},
|
||||
"enabledToast": "已启用同步",
|
||||
"disabledToast": "已禁用同步",
|
||||
"enabledDescription": "已启用配置文件同步",
|
||||
"disabledDescription": "已禁用配置文件同步"
|
||||
},
|
||||
"integrations": {
|
||||
"title": "集成",
|
||||
@@ -918,6 +926,11 @@
|
||||
"syncingProfile": "正在同步配置文件 '{{name}}'...",
|
||||
"syncingProfileWithProgress": "{{count}} 个文件 ({{size}})",
|
||||
"updatingVersions": "正在更新浏览器版本..."
|
||||
},
|
||||
"progress": {
|
||||
"remaining": "剩余 {{time}}",
|
||||
"filesProgress": "{{completed}}/{{total}} 个文件",
|
||||
"filesFailed": "{{count}} 个文件失败"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
@@ -1136,7 +1149,9 @@
|
||||
"addRule": "添加规则",
|
||||
"rulePlaceholder": "例如 example.com, 192.168.1.*, .*\\.local",
|
||||
"noRules": "未配置绕过规则。",
|
||||
"ruleTypes": "支持主机名、IP地址和正则表达式模式。"
|
||||
"ruleTypes": "支持主机名、IP地址和正则表达式模式。",
|
||||
"vpnLabel": "VPN:{{name}}",
|
||||
"proxyLabel": "代理:{{name}}"
|
||||
},
|
||||
"launchHook": {
|
||||
"title": "启动钩子 URL",
|
||||
@@ -1196,8 +1211,14 @@
|
||||
},
|
||||
"fingerprint": {
|
||||
"notSupported": "指纹编辑仅适用于 Camoufox 和 Wayfern 配置文件。",
|
||||
"lockedTitle": "指纹是 Pro 功能",
|
||||
"lockedDescription": "查看和编辑配置文件的指纹需要有效的付费方案。升级后即可解锁指纹保护。"
|
||||
"lockedTitle": "查看和编辑指纹是 Pro 功能",
|
||||
"lockedDescription": "所有方案都包含指纹保护。查看和编辑配置文件的指纹数值才需要有效的付费方案。"
|
||||
},
|
||||
"syncStatusValue": {
|
||||
"waiting": "等待中",
|
||||
"syncing": "同步中",
|
||||
"synced": "已同步",
|
||||
"error": "错误"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1718,7 +1739,8 @@
|
||||
"updateStartedDescription": "版本 {{version}} 即将开始下载。更新完成前浏览器启动将被禁用。",
|
||||
"downloadStarting": "正在开始下载 {{browser}} {{version}}",
|
||||
"downloadProgressBelow": "下载进度将显示在下方...",
|
||||
"autoDownloadStarted": "正在自动下载 {{browser}} {{version}}。进度将显示在下方。"
|
||||
"autoDownloadStarted": "正在自动下载 {{browser}} {{version}}。进度将显示在下方。",
|
||||
"lookingForUpdates": "正在检查 {{browser}} 的更新"
|
||||
}
|
||||
},
|
||||
"profilePassword": {
|
||||
@@ -1807,10 +1829,11 @@
|
||||
"cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。",
|
||||
"cookieDbUnavailable": "无法读取 Cookie — Cookie 存储不可用。",
|
||||
"selfHostedRequiresLogout": "在配置自托管服务器之前请先退出 Donut 账户。",
|
||||
"fingerprintRequiresPro": "指纹保护需要有效的付费方案。",
|
||||
"fingerprintRequiresPro": "查看或编辑指纹需要有效的付费方案。所有方案均包含指纹保护。",
|
||||
"proxyNotWorking": "所选代理无法使用,因此未创建配置文件。",
|
||||
"proxyPaymentRequired": "所选代理需要付费(402),其订阅可能已过期,因此未创建配置文件。",
|
||||
"vpnNotWorking": "所选 VPN 无法使用,因此未创建配置文件。"
|
||||
"vpnNotWorking": "所选 VPN 无法使用,因此未创建配置文件。",
|
||||
"camoufoxImportDeprecated": "不再支持导入基于 Firefox 的 (Camoufox) 配置文件。Camoufox 即将停用——请改用 Wayfern。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "配置文件",
|
||||
@@ -1939,7 +1962,7 @@
|
||||
},
|
||||
"browserSupport": {
|
||||
"endingSoonTitle": "浏览器支持即将结束",
|
||||
"endingSoonDescription": "以下配置文件的支持将于 2026 年 3 月 15 日移除:{{profiles}}。请迁移到 Wayfern 或 Camoufox 配置文件。"
|
||||
"endingSoonDescription": "以下配置文件的支持将于 2026 年 3 月 15 日移除:{{profiles}}。请迁移到 Wayfern 配置文件。"
|
||||
},
|
||||
"onboarding": {
|
||||
"steps": {
|
||||
@@ -2017,5 +2040,14 @@
|
||||
"trialBadge": "2 周免费",
|
||||
"commercialDesc": "在 2 周评估期内免费。之后需要付费方案,这有助于本项目的持续维护与发展。"
|
||||
}
|
||||
},
|
||||
"wayfernBlocked": {
|
||||
"title": "浏览器自动化已暂停",
|
||||
"description": "您的账户暂时被限制使用 Pro 浏览器功能,通常是因为同时在多台设备上登录。请退出其他设备的登录,然后重新启动配置文件即可恢复。"
|
||||
},
|
||||
"camoufoxDeprecation": {
|
||||
"title": "Camoufox 支持即将结束",
|
||||
"description": "Camoufox 配置文件的支持将于 2026 年 7 月 8 日结束。您有一个或多个 Camoufox 配置文件。请在此之前迁移到 Wayfern——之后 Camoufox 配置文件可能会停止工作。",
|
||||
"acknowledge": "知道了"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export type BackendErrorCode =
|
||||
| "PROXY_NOT_WORKING"
|
||||
| "PROXY_PAYMENT_REQUIRED"
|
||||
| "VPN_NOT_WORKING"
|
||||
| "CAMOUFOX_IMPORT_DEPRECATED"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
export interface BackendError {
|
||||
@@ -132,6 +133,8 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.proxyPaymentRequired");
|
||||
case "VPN_NOT_WORKING":
|
||||
return t("backendErrors.vpnNotWorking");
|
||||
case "CAMOUFOX_IMPORT_DEPRECATED":
|
||||
return t("backendErrors.camoufoxImportDeprecated");
|
||||
case "INTERNAL_ERROR":
|
||||
return t("backendErrors.internal", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { CloudUser, Entitlements } from "@/types";
|
||||
|
||||
const DEFAULT_REQUESTS_PER_HOUR = 100;
|
||||
|
||||
interface Capabilities {
|
||||
browserAutomation: boolean;
|
||||
crossOsFingerprints: boolean;
|
||||
cloudBackup: boolean;
|
||||
teamCollaboration: boolean;
|
||||
}
|
||||
|
||||
const NONE: Entitlements = {
|
||||
active: false,
|
||||
browserAutomation: false,
|
||||
crossOsFingerprints: false,
|
||||
cloudBackup: false,
|
||||
teamCollaboration: false,
|
||||
profileLimit: 0,
|
||||
requestsPerHour: 0,
|
||||
};
|
||||
|
||||
// Mirror of PLAN_CAPABILITIES in apps/backend/src/plans/entitlements.ts. Keep in
|
||||
// sync — a new plan must be declared here too, or it falls back to DEFAULT_PAID.
|
||||
const PLAN_CAPABILITIES: Record<string, Capabilities> = {
|
||||
starter: {
|
||||
browserAutomation: false,
|
||||
crossOsFingerprints: true,
|
||||
cloudBackup: true,
|
||||
teamCollaboration: false,
|
||||
},
|
||||
pro: {
|
||||
browserAutomation: true,
|
||||
crossOsFingerprints: true,
|
||||
cloudBackup: true,
|
||||
teamCollaboration: false,
|
||||
},
|
||||
team: {
|
||||
browserAutomation: true,
|
||||
crossOsFingerprints: true,
|
||||
cloudBackup: true,
|
||||
teamCollaboration: true,
|
||||
},
|
||||
enterprise: {
|
||||
browserAutomation: true,
|
||||
crossOsFingerprints: true,
|
||||
cloudBackup: true,
|
||||
teamCollaboration: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Unknown paid plan -> pro-level (never team), matching the backend default.
|
||||
const DEFAULT_PAID: Capabilities = {
|
||||
browserAutomation: true,
|
||||
crossOsFingerprints: true,
|
||||
cloudBackup: true,
|
||||
teamCollaboration: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* The user's effective entitlements. Prefers the backend-resolved object the
|
||||
* desktop attaches to CloudUser; only falls back to deriving from the plan
|
||||
* fields when it's missing (older cached state). The fallback mirrors the
|
||||
* backend matrix in `apps/backend/src/plans/entitlements.ts`.
|
||||
*/
|
||||
export function getEntitlements(
|
||||
user: CloudUser | null | undefined,
|
||||
): Entitlements {
|
||||
if (user?.entitlements) return user.entitlements;
|
||||
if (!user) return NONE;
|
||||
|
||||
const active =
|
||||
user.plan !== "free" &&
|
||||
(user.subscriptionStatus === "active" || user.planPeriod === "lifetime");
|
||||
if (!active) return NONE;
|
||||
|
||||
const caps = PLAN_CAPABILITIES[user.plan] ?? DEFAULT_PAID;
|
||||
return {
|
||||
active: true,
|
||||
browserAutomation: caps.browserAutomation,
|
||||
crossOsFingerprints: caps.crossOsFingerprints,
|
||||
cloudBackup: caps.cloudBackup,
|
||||
teamCollaboration: caps.teamCollaboration,
|
||||
profileLimit: user.profileLimit,
|
||||
requestsPerHour: caps.browserAutomation ? DEFAULT_REQUESTS_PER_HOUR : 0,
|
||||
};
|
||||
}
|
||||
+17
-24
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import React from "react";
|
||||
import { type ExternalToast, toast as sonnerToast } from "sonner";
|
||||
import { UnifiedToast } from "@/components/custom-toast";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
interface BaseToastProps {
|
||||
id?: string;
|
||||
@@ -29,12 +30,7 @@ interface ErrorToastProps extends BaseToastProps {
|
||||
|
||||
interface DownloadToastProps extends BaseToastProps {
|
||||
type: "download";
|
||||
stage?:
|
||||
| "downloading"
|
||||
| "extracting"
|
||||
| "verifying"
|
||||
| "completed"
|
||||
| "downloading (twilight rolling release)";
|
||||
stage?: "downloading" | "extracting" | "verifying" | "completed";
|
||||
progress?: {
|
||||
percentage: number;
|
||||
speed?: string;
|
||||
@@ -159,25 +155,19 @@ export function showToast(props: ToastProps & { id?: string }) {
|
||||
export function showDownloadToast(
|
||||
browserName: string,
|
||||
version: string,
|
||||
stage:
|
||||
| "downloading"
|
||||
| "extracting"
|
||||
| "verifying"
|
||||
| "completed"
|
||||
| "downloading (twilight rolling release)",
|
||||
stage: "downloading" | "extracting" | "verifying" | "completed",
|
||||
progress?: { percentage: number; speed?: string; eta?: string },
|
||||
options?: { suppressCompletionToast?: boolean; onCancel?: () => void },
|
||||
) {
|
||||
const tParams = { browser: browserName, version };
|
||||
const title =
|
||||
stage === "completed"
|
||||
? `${browserName} ${version} downloaded successfully!`
|
||||
? i18n.t("toasts.success.downloadComplete", tParams)
|
||||
: stage === "downloading"
|
||||
? `Downloading ${browserName} ${version}`
|
||||
? i18n.t("toasts.loading.downloading", tParams)
|
||||
: stage === "extracting"
|
||||
? `Extracting ${browserName} ${version}`
|
||||
: stage === "downloading (twilight rolling release)"
|
||||
? `Downloading ${browserName} ${version}`
|
||||
: `Verifying ${browserName} ${version}`;
|
||||
? i18n.t("toasts.loading.extracting", tParams)
|
||||
: i18n.t("toasts.loading.verifying", tParams);
|
||||
|
||||
// Don't show completion toast if suppressed (for auto-update scenarios)
|
||||
if (stage === "completed" && options?.suppressCompletionToast) {
|
||||
@@ -186,9 +176,7 @@ export function showDownloadToast(
|
||||
}
|
||||
|
||||
// Only show cancel button during active downloading, not for completed/extracting/verifying
|
||||
const showCancel =
|
||||
stage === "downloading" ||
|
||||
stage === "downloading (twilight rolling release)";
|
||||
const showCancel = stage === "downloading";
|
||||
|
||||
return showToast({
|
||||
type: "download",
|
||||
@@ -241,10 +229,15 @@ export function showAutoUpdateToast(
|
||||
) {
|
||||
return showToast({
|
||||
type: "loading",
|
||||
title: `${browserName} update started`,
|
||||
title: i18n.t("versionUpdater.toast.updateStarted", {
|
||||
browser: browserName,
|
||||
}),
|
||||
description:
|
||||
options?.description ??
|
||||
`Automatically downloading ${browserName} ${version}. Progress will be shown in download notifications.`,
|
||||
i18n.t("versionUpdater.toast.autoDownloadStarted", {
|
||||
browser: browserName,
|
||||
version,
|
||||
}),
|
||||
id: options?.id ?? `auto-update-${browserName.toLowerCase()}-${version}`,
|
||||
duration: options?.duration ?? 4000,
|
||||
});
|
||||
@@ -270,7 +263,7 @@ export function showSyncProgressToast(
|
||||
) {
|
||||
return showToast({
|
||||
type: "sync-progress",
|
||||
title: `Syncing profile '${profileName}'...`,
|
||||
title: i18n.t("toasts.loading.syncingProfile", { name: profileName }),
|
||||
progress,
|
||||
id: options?.id,
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
|
||||
@@ -75,6 +75,24 @@ export interface SyncSettings {
|
||||
sync_token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability/limit set derived from the plan by the backend. Features are gated
|
||||
* on these flags instead of a single "is paid?" check, so a plan like the future
|
||||
* "starter" tier (cross-OS fingerprints + cloud backup, no automation) is just
|
||||
* data. Mirrors `apps/backend/src/plans/entitlements.ts`. Resolve via
|
||||
* `getEntitlements()` — the desktop populates it, but it stays optional for
|
||||
* safety on older state.
|
||||
*/
|
||||
export interface Entitlements {
|
||||
active: boolean;
|
||||
browserAutomation: boolean;
|
||||
crossOsFingerprints: boolean;
|
||||
cloudBackup: boolean;
|
||||
teamCollaboration: boolean;
|
||||
profileLimit: number;
|
||||
requestsPerHour: number;
|
||||
}
|
||||
|
||||
export interface CloudUser {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -95,6 +113,9 @@ export interface CloudUser {
|
||||
deviceOrdinal?: number | null;
|
||||
deviceCount?: number | null;
|
||||
isPrimaryDevice?: boolean | null;
|
||||
// Plan-derived capabilities. The desktop resolves this before handing CloudUser
|
||||
// to the UI; optional to stay safe on older cached state.
|
||||
entitlements?: Entitlements;
|
||||
}
|
||||
|
||||
export interface ProfileLockInfo {
|
||||
|
||||
Reference in New Issue
Block a user