mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-11 17:27:54 +02:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55de231a37 | |||
| aab403fd9b | |||
| 667a4c99f0 | |||
| 9236ad38c8 | |||
| 6850f2c573 | |||
| 0add6c2aae | |||
| f54c359d15 | |||
| 69da467ce0 | |||
| 375530e358 | |||
| d664e5cde6 | |||
| 096e4aaf4a | |||
| 8305c45cb5 | |||
| ff3634e6cc | |||
| 36263eac04 | |||
| 9e777ed37b | |||
| 4d59805989 | |||
| 28d135de06 | |||
| d234172d0a | |||
| 6cd257c40b | |||
| 7446f678d4 | |||
| 72e2b99b9e | |||
| 98b83aaf5a | |||
| 99074280ea | |||
| 85586ed8fa |
@@ -0,0 +1,23 @@
|
||||
messages:
|
||||
- role: system
|
||||
content: |-
|
||||
You write short, friendly release summaries for Donut Browser, an anti-detect browser desktop app built with Tauri and Next.js.
|
||||
|
||||
Rules:
|
||||
- Keep it minimal and friendly. No marketing voice, no filler, no superlatives.
|
||||
- No emojis or pictographic symbols.
|
||||
- Plain ASCII punctuation only. No em-dashes, en-dashes, ellipses, smart quotes, or any non-ASCII characters. Use a regular hyphen, three dots, or straight quotes instead.
|
||||
- Plain text only. No markdown (no asterisks for bold, no backticks for code, no headings), no HTML tags.
|
||||
- Focus on user-visible changes. Skip chore, docs-only, CI, test, dependency, formatting, and purely internal refactor commits unless they have user-visible impact.
|
||||
- Group related commits into a single bullet when it reads better.
|
||||
- Use simple, direct language.
|
||||
- Do not include the version number, download links, or a heading. The surrounding message already has those.
|
||||
- If nothing in the commits is user-visible, output exactly one bullet: "- Small fixes and internal improvements."
|
||||
- role: user
|
||||
content: |-
|
||||
Write the summary for Donut Browser {{version}} from these commits:
|
||||
|
||||
{{commits}}
|
||||
|
||||
Format: one short opening sentence, a blank line, then bullets starting with "- " (one per line). Nothing else.
|
||||
model: openai/gpt-4.1
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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`);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -33,10 +33,10 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 #v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee #v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
echo "Tags: ${TAGS}"
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f #v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf #v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: ./donut-sync/Dockerfile
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
name: Issue Compliance Check
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
env:
|
||||
MODEL: z-ai/glm-5.1
|
||||
|
||||
jobs:
|
||||
check-compliance:
|
||||
if: github.repository == 'zhom/donutbrowser' && github.event.action == 'opened'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Gather context
|
||||
env:
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
run: |
|
||||
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
|
||||
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
|
||||
|
||||
- name: Build prompt
|
||||
run: |
|
||||
cat > /tmp/system.txt <<'PROMPT'
|
||||
You are reviewing a new GitHub issue for template compliance. Return ONLY a single JSON object, no prose, no markdown fences.
|
||||
|
||||
Project: Donut Browser. There are three valid templates:
|
||||
- Bug Report (Description + Operating System + Donut Browser version + Which browser is affected + Steps to reproduce + Error logs/screenshots fields)
|
||||
- Feature Request (description + verification checkbox)
|
||||
- Question (free form)
|
||||
|
||||
## Compliance — flag NON-compliant ONLY when at least one of these is true
|
||||
- The issue body is empty or contains only placeholder text from the template
|
||||
- The issue is an obvious AI-generated wall of text with no real specifics
|
||||
- A bug report has no reproduction information or no error description
|
||||
- A feature request gives no use case at all
|
||||
- The author left required fields empty (Operating System, Donut Browser version, Which browser is affected, Steps to reproduce on bug reports)
|
||||
|
||||
Do NOT flag for missing optional fields, missing screenshots, short titles, or stylistic issues. Be conservative.
|
||||
|
||||
## Output schema
|
||||
{
|
||||
"is_compliant": true | false,
|
||||
"non_compliance_reasons": ["short bullet", ...]
|
||||
}
|
||||
|
||||
If there is nothing to flag, return:
|
||||
{"is_compliant": true, "non_compliance_reasons": []}
|
||||
PROMPT
|
||||
|
||||
- name: Call OpenRouter
|
||||
env:
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
run: |
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg model "$MODEL" \
|
||||
--rawfile system_prompt /tmp/system.txt \
|
||||
--rawfile title /tmp/issue-title.txt \
|
||||
--rawfile body /tmp/issue-body.txt \
|
||||
'{
|
||||
model: $model,
|
||||
messages: [
|
||||
{ role: "system", content: $system_prompt },
|
||||
{ role: "user",
|
||||
content: ("New issue title: " + $title + "\n\nNew issue body:\n" + $body) }
|
||||
],
|
||||
response_format: { type: "json_object" }
|
||||
}')
|
||||
|
||||
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")
|
||||
|
||||
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/raw.txt
|
||||
|
||||
# Strip accidental markdown fences and parse. On parse failure, fall back
|
||||
# to a noop result so the workflow doesn't fail the issue author's run.
|
||||
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
|
||||
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
|
||||
echo "::warning::Model returned non-JSON; treating as compliant"
|
||||
cat /tmp/raw.txt
|
||||
echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json
|
||||
fi
|
||||
echo "Result:"
|
||||
cat /tmp/result.json
|
||||
|
||||
- name: Build comment
|
||||
run: |
|
||||
python3 - <<'EOF'
|
||||
import json, os
|
||||
r = json.load(open('/tmp/result.json'))
|
||||
compliant = bool(r.get('is_compliant', True))
|
||||
reasons = r.get('non_compliance_reasons') or []
|
||||
|
||||
parts = []
|
||||
if not compliant:
|
||||
parts.append('<!-- issue-compliance -->')
|
||||
parts.append("This issue doesn't fully meet our [contributing guidelines](../blob/main/CONTRIBUTING.md).")
|
||||
parts.append('')
|
||||
parts.append('**What 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.')
|
||||
parts.append('')
|
||||
parts.append('If you believe this was flagged incorrectly, please let a maintainer know.')
|
||||
|
||||
comment = '\n'.join(parts).strip()
|
||||
open('/tmp/comment.md', 'w').write(comment)
|
||||
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
|
||||
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
|
||||
@@ -18,8 +18,8 @@ permissions:
|
||||
|
||||
env:
|
||||
# Single source of truth for the model used by both triage and composer.
|
||||
TRIAGE_MODEL: anthropic/claude-opus-4.7
|
||||
COMPOSER_MODEL: anthropic/claude-opus-4.7
|
||||
TRIAGE_MODEL: z-ai/glm-5.1
|
||||
COMPOSER_MODEL: z-ai/glm-5.1
|
||||
|
||||
jobs:
|
||||
analyze-issue:
|
||||
@@ -102,12 +102,14 @@ jobs:
|
||||
its API, MCP server, and the bundled `donut-sync` self-hosted server.
|
||||
- **Wayfern** — a Chromium fork maintained by zhom (the same maintainer). Wayfern
|
||||
bugs are in-scope here unless they are obviously upstream Chromium issues.
|
||||
- **Camoufox** — a Firefox fork by daijro. The maintainer of THIS repo does NOT
|
||||
contribute to Camoufox and CANNOT fix bugs in it.
|
||||
- **Camoufox** — a Firefox fork by daijro, used by Donut but maintained in a
|
||||
separate repository. Bugs about Camoufox's *internal* behavior are outside
|
||||
the scope of this project.
|
||||
- Bugs about Camoufox's *internal* behavior (page rendering, JS engine,
|
||||
dropdowns, form widgets, fingerprinting *as Camoufox implements it*,
|
||||
checkbox/radio quirks) are UPSTREAM ONLY. Redirect to
|
||||
https://github.com/daijro/camoufox/issues.
|
||||
checkbox/radio quirks) are out of scope here. Ask the user to first
|
||||
search https://github.com/daijro/camoufox/issues for a matching report,
|
||||
and if they don't find one, to open it there themselves.
|
||||
- Bugs about how Donut *launches, configures, or downloads* Camoufox are
|
||||
in-scope here.
|
||||
- **Forks of Wayfern or Camoufox** (e.g. CloverLabsAI, VulpineOS) are NOT
|
||||
@@ -146,7 +148,10 @@ jobs:
|
||||
dismiss as "known issue" / "expected" / "false positive in Tauri apps". Ask
|
||||
which exact version was the last working one and what changed.
|
||||
- **Out-of-scope (upstream Camoufox)**: report is about Camoufox's own
|
||||
behavior. Redirect, do not collect logs.
|
||||
behavior. Tell the user it's outside the scope of this project and ask
|
||||
them to search the Camoufox repo and, if no matching issue exists, file
|
||||
one there. Do NOT say the maintainer doesn't contribute / can't fix it
|
||||
— keep it strictly about project scope. Do not collect logs.
|
||||
- **Fork-support request**: asks the maintainer to support an alternative
|
||||
Wayfern/Camoufox fork. Acknowledge in one neutral sentence — do NOT call it
|
||||
"clear", "reasonable", "well-thought-out", etc.
|
||||
@@ -342,7 +347,7 @@ jobs:
|
||||
The triage classification (`triage.classification`) determines the response shape:
|
||||
|
||||
- `bug-in-scope`: ask for what is missing using the user's reported OS log path. Be concrete about how to obtain logs.
|
||||
- `bug-upstream-camoufox`: redirect ONLY. One sentence acknowledging, then a sentence saying this is a Camoufox-internal issue and the maintainer of this repo does not contribute to Camoufox; ask the user to file at https://github.com/daijro/camoufox/issues. Do NOT ask for Donut logs. Stop after that.
|
||||
- `bug-upstream-camoufox`: redirect ONLY. One sentence acknowledging, then say this is outside the scope of this project — ask the user to first search https://github.com/daijro/camoufox/issues for a matching report and, if none exists, to open one there themselves. Do NOT phrase it as "the maintainer does not contribute" or anything personal — keep it strictly about scope. Do NOT ask for Donut logs. Stop after that.
|
||||
- `bug-template-violation` or `ai-generated-junk`: politely ask the user to refile using the bug-report template (the Operating System, Donut Browser version, Which browser, Steps to reproduce, Error logs sections). If they cited "documentation" from any non-`donutbrowser.com`/non-`github.com/zhom` URL (e.g. context7, deepwiki), gently note that those are AI-generated third-party summaries and the only authoritative sources are this repo and donutbrowser.com.
|
||||
- `feature-request`: one neutral sentence acknowledging, then ask only what is genuinely needed (concrete use case, whether a workaround would suffice). Do NOT validate.
|
||||
- `fork-request`: one neutral sentence acknowledging the request. Note that this would substantially increase support burden and the maintainer evaluates such requests on a case-by-case basis. Ask whether the alternative fork supports all platforms the user uses (macOS / Windows / Linux). No "clear enhancement" language.
|
||||
@@ -615,7 +620,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@8ba2a9171597262df9d19516c82a5e14f18f5c63 #v1.14.41
|
||||
uses: anomalyco/opencode/github@d74d166acf40e51146f8547216913a4e787a4bc1 #v1.15.10
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
models: read
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
@@ -105,21 +106,12 @@ jobs:
|
||||
fi
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Post release announcement to Telegram
|
||||
- name: Collect commits between previous tag and current tag
|
||||
id: commits
|
||||
if: steps.gate.outputs.skip != 'true'
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||
echo "::warning::TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set — skipping Telegram notification."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find the previous stable tag (skip the current one) so the
|
||||
# changelog range is well-defined.
|
||||
PREV_TAG=$(git tag --sort=-version:refname \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| grep -v "^${TAG}$" \
|
||||
@@ -127,29 +119,52 @@ jobs:
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
|
||||
fi
|
||||
git log --pretty=format:"- %s (%h)" "${PREV_TAG}..${TAG}" --no-merges > commits.txt
|
||||
echo "previous-tag=${PREV_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "Collected $(wc -l < commits.txt) commits between ${PREV_TAG} and ${TAG}."
|
||||
|
||||
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
|
||||
- name: Generate summary with AI
|
||||
id: ai
|
||||
if: steps.gate.outputs.skip != 'true'
|
||||
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
|
||||
with:
|
||||
prompt-file: .github/prompts/telegram-release-summary.prompt.yml
|
||||
input: |
|
||||
version: ${{ steps.tag.outputs.tag }}
|
||||
file_input: |
|
||||
commits: ./commits.txt
|
||||
max-tokens: 1024
|
||||
|
||||
# Build a plain bullet list from feat / fix / refactor commits.
|
||||
# Other commit types (chore, docs, ci, test, deps) are intentionally
|
||||
# filtered out to keep the channel focused on user-visible changes.
|
||||
CHANGES=""
|
||||
while IFS= read -r msg; do
|
||||
[ -z "$msg" ] && continue
|
||||
case "$msg" in
|
||||
feat\(*\):*|feat:*|fix\(*\):*|fix:*|refactor\(*\):*|refactor:*)
|
||||
CHANGES="${CHANGES}• $(strip_prefix "$msg")"$'\n'
|
||||
;;
|
||||
esac
|
||||
done < <(git log --pretty=format:%s "${PREV_TAG}..${TAG}")
|
||||
|
||||
if [ -z "$CHANGES" ]; then
|
||||
CHANGES="• See release notes."$'\n'
|
||||
- name: Post release announcement to Telegram
|
||||
if: steps.gate.outputs.skip != 'true'
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
REPO: ${{ github.repository }}
|
||||
AI_RESPONSE_FILE: ${{ steps.ai.outputs.response-file }}
|
||||
AI_RESPONSE: ${{ steps.ai.outputs.response }}
|
||||
run: |
|
||||
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||
echo "::warning::TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set — skipping Telegram notification."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# HTML-escape the changelog before injecting into Telegram HTML
|
||||
# mode — commit messages can legitimately contain `<`, `>`, `&`.
|
||||
ESCAPED_CHANGES=$(printf '%s' "$CHANGES" \
|
||||
# Prefer the file output — `response` can be truncated for longer summaries.
|
||||
if [ -n "$AI_RESPONSE_FILE" ] && [ -f "$AI_RESPONSE_FILE" ]; then
|
||||
SUMMARY=$(cat "$AI_RESPONSE_FILE")
|
||||
else
|
||||
SUMMARY="$AI_RESPONSE"
|
||||
fi
|
||||
|
||||
if [ -z "${SUMMARY//[[:space:]]/}" ]; then
|
||||
echo "::error::AI summary is empty"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# HTML-escape the AI summary before injecting into Telegram HTML mode —
|
||||
# commit messages can legitimately contain `<`, `>`, `&` and the AI may echo them.
|
||||
ESCAPED_CHANGES=$(printf '%s' "$SUMMARY" \
|
||||
| python3 -c "import html, sys; sys.stdout.write(html.escape(sys.stdin.read()))")
|
||||
|
||||
VERSION="${TAG}"
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
scan-scheduled:
|
||||
name: Scheduled Security Scan
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
scan-pr:
|
||||
name: PR Security Scan
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Generate release notes with AI
|
||||
id: generate-notes
|
||||
if: steps.get-release.outputs.is-prerelease == 'false'
|
||||
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
|
||||
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
|
||||
with:
|
||||
prompt-file: .github/prompts/release-notes.prompt.yml
|
||||
input: |
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
security-scan:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
security-scan:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -23,4 +23,4 @@ jobs:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@5374cbf686e897b15713110e233094e2874de7ef #v1.46.1
|
||||
uses: crate-ci/typos@aca895bf05aec0cb7dffa6f94495e923224d9f17 #v1.46.2
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open."
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ donutbrowser/
|
||||
- After making changes, run `pnpm format && pnpm lint && pnpm test` at the root of the project
|
||||
- Always run this command before finishing a task to ensure the application isn't broken
|
||||
- `pnpm lint` includes spellcheck via [typos](https://github.com/crate-ci/typos). False positives can be allowlisted in `_typos.toml`
|
||||
- The full `pnpm test` output dumps every test name (≈400+ lines) which burns context for no signal. Filter:
|
||||
`pnpm test 2>&1 | grep -E "test result|panicked|FAILED"` — four "test result: ok" lines means everything passed.
|
||||
|
||||
## Code Quality
|
||||
|
||||
@@ -122,6 +124,34 @@ A `<Dialog>` becomes a first-class app sub-page (no modal overlay, no center pos
|
||||
|
||||
Reference implementations: `src/components/account-page.tsx`, `src/components/proxy-management-dialog.tsx`. Reuse the exact class strings — the overrides are tuned to match the rest of the sub-page chrome.
|
||||
|
||||
### Cross-component tab control
|
||||
|
||||
When a tabbed sub-page dialog needs to be opened to a specific tab by an external trigger (e.g. a keyboard shortcut that toggles `proxies` ↔ `vpns`), expose an `initialTab` prop and key the `Tabs` component off it. The `key` change forces a remount so the new tab is selected even though the internal `activeTab` state is otherwise sticky:
|
||||
|
||||
```tsx
|
||||
<AnimatedTabs key={initialTab} defaultValue={initialTab} ...>
|
||||
```
|
||||
|
||||
Reference implementations: `proxy-management-dialog.tsx`, `extension-management-dialog.tsx`, `integrations-dialog.tsx`. The owning page in `src/app/page.tsx` keeps one piece of `useState` per dialog (`proxyManagementInitialTab`, `extensionManagementInitialTab`, `integrationsInitialTab`) and flips it on repeated shortcut presses.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
All app-wide shortcuts live in `src/lib/shortcuts.ts`:
|
||||
|
||||
- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in all seven 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.
|
||||
|
||||
Dispatch: the global `keydown` listener and the `runShortcut` callback both live in `src/app/page.tsx`. To add a new static shortcut:
|
||||
|
||||
1. Append to `SHORTCUTS` in `src/lib/shortcuts.ts`. Add the `ShortcutId` variant.
|
||||
2. Add a `case "yourId":` in `runShortcut` in `page.tsx`.
|
||||
3. Add the icon mapping in `src/components/command-palette.tsx::ICONS`.
|
||||
4. Add `shortcuts.yourId` (label) to all seven locale files.
|
||||
|
||||
The command palette (Mod+K) is built on the shadcn `Command` primitive with a token-AND fuzzy filter — `fuzzyFilter` in `command-palette.tsx`. The `CommandDialog` wrapper now forwards `filter`/`shouldFilter` to the inner `Command` for callers that need custom matching.
|
||||
|
||||
## Singletons
|
||||
|
||||
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## v0.24.2 (2026-05-16)
|
||||
|
||||
### Features
|
||||
|
||||
- more mcp integrations
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- camoufox proxy pid connection
|
||||
|
||||
### Refactoring
|
||||
|
||||
- browser update
|
||||
- ui cleanup
|
||||
- cleanup
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: cleanup
|
||||
- chore: update flake.nix for v0.24.1 [skip ci] (#364)
|
||||
|
||||
|
||||
## v0.24.1 (2026-05-12)
|
||||
|
||||
### Refactoring
|
||||
|
||||
@@ -19,9 +19,6 @@
|
||||
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/network/members" target="_blank">
|
||||
<img src="https://img.shields.io/github/forks/zhom/donutbrowser?style=social" alt="GitHub forks">
|
||||
</a>
|
||||
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/releases" target="_blank">
|
||||
<img src="https://img.shields.io/github/downloads/zhom/donutbrowser/total" alt="Downloads">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<img alt="Donut Browser Preview" src="assets/donut-preview.png" />
|
||||
@@ -30,6 +27,7 @@
|
||||
|
||||
- **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data
|
||||
- **Chromium & Firefox engines** — Chromium powered by [Wayfern](https://wayfern.com), Firefox powered by [Camoufox](https://camoufox.com), both with advanced fingerprint spoofing
|
||||
- **DNS AdBlocker** - block ads, trackers, and other unwanted content with per-profile DNS blocking
|
||||
- **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
|
||||
- **VPN support** — WireGuard configs per profile
|
||||
- **Local API & MCP** — REST API and [Model Context Protocol](https://modelcontextprotocol.io) server for integration with Claude, automation tools, and custom workflows
|
||||
@@ -48,7 +46,7 @@
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_x64.dmg) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
@@ -58,15 +56,15 @@ brew install --cask donut
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_x64-portable.zip)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut-0.24.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut-0.24.1-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_aarch64.AppImage) |
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut-0.24.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut-0.24.2-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 623 KiB After Width: | Height: | Size: 508 KiB |
@@ -94,17 +94,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.24.1";
|
||||
releaseVersion = "0.24.2";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_amd64.AppImage";
|
||||
hash = "sha256-nJ4WmbXQcnXWDaneucOlwzZmlOOBx+G/qDeCHH6/Vno=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_amd64.AppImage";
|
||||
hash = "sha256-140PSB/1BLGUB4sI/RgfYe7uUjwRFWXtdSnUZz6Wr0U=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_aarch64.AppImage";
|
||||
hash = "sha256-aLzHAdn+o9YsnKtK5BpjjrzAAbp/itsN1QdELTpHyTQ=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_aarch64.AppImage";
|
||||
hash = "sha256-QPGV6XO0ugPAJSbPJrVwDsEb9lw3dcL6IdU17UCYH4E=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
+2
-12
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.24.2",
|
||||
"version": "0.24.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 12341",
|
||||
@@ -89,17 +89,7 @@
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"picomatch@>=4.0.0 <4.0.4": ">=4.0.4",
|
||||
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0",
|
||||
"postcss@<8.5.10": ">=8.5.12",
|
||||
"fast-xml-parser@<5.7.0": ">=5.7.2",
|
||||
"fast-uri@<3.1.2": ">=3.1.2",
|
||||
"fast-xml-builder@<1.2.0": ">=1.2.0"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"packageManager": "pnpm@11.2.2",
|
||||
"lint-staged": {
|
||||
"**/*.{js,jsx,ts,tsx,json,css}": [
|
||||
"biome check --fix"
|
||||
|
||||
Generated
+27
-26
@@ -11,6 +11,8 @@ overrides:
|
||||
fast-xml-parser@<5.7.0: '>=5.7.2'
|
||||
fast-uri@<3.1.2: '>=3.1.2'
|
||||
fast-xml-builder@<1.2.0: '>=1.2.0'
|
||||
qs@>=6.11.1 <6.15.2: '>=6.15.2'
|
||||
js-cookie@<3.0.7: '>=3.0.7'
|
||||
|
||||
importers:
|
||||
|
||||
@@ -212,7 +214,7 @@ importers:
|
||||
devDependencies:
|
||||
'@nestjs/cli':
|
||||
specifier: ^11.0.21
|
||||
version: 11.0.21(@types/node@25.7.0)(lightningcss@1.32.0)
|
||||
version: 11.0.21(@types/node@25.7.0)
|
||||
'@nestjs/schematics':
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0(chokidar@4.0.3)(typescript@6.0.3)
|
||||
@@ -248,7 +250,7 @@ importers:
|
||||
version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.4.2(@types/node@25.7.0)(ts-node@10.9.2(@types/node@25.7.0)(typescript@6.0.3)))(typescript@6.0.3)
|
||||
ts-loader:
|
||||
specifier: ^9.5.7
|
||||
version: 9.5.7(typescript@6.0.3)(webpack@5.106.0(lightningcss@1.32.0))
|
||||
version: 9.5.7(typescript@6.0.3)(webpack@5.106.0)
|
||||
ts-node:
|
||||
specifier: ^10.9.2
|
||||
version: 10.9.2(@types/node@25.7.0)(typescript@6.0.3)
|
||||
@@ -2060,6 +2062,7 @@ packages:
|
||||
'@smithy/core@3.24.1':
|
||||
resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
deprecated: Deprecated due to bug in browser bundling instructions https://github.com/smithy-lang/smithy-typescript/issues/2025
|
||||
|
||||
'@smithy/credential-provider-imds@4.3.1':
|
||||
resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==}
|
||||
@@ -3872,9 +3875,9 @@ packages:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
js-cookie@3.0.5:
|
||||
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
|
||||
engines: {node: '>=14'}
|
||||
js-cookie@3.0.7:
|
||||
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
@@ -4401,8 +4404,8 @@ packages:
|
||||
pure-rand@7.0.1:
|
||||
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
|
||||
|
||||
qs@6.15.1:
|
||||
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
|
||||
qs@6.15.2:
|
||||
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
radix-ui@1.4.3:
|
||||
@@ -6421,7 +6424,7 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.2
|
||||
optional: true
|
||||
|
||||
'@nestjs/cli@11.0.21(@types/node@25.7.0)(lightningcss@1.32.0)':
|
||||
'@nestjs/cli@11.0.21(@types/node@25.7.0)':
|
||||
dependencies:
|
||||
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
|
||||
'@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
|
||||
@@ -6432,14 +6435,14 @@ snapshots:
|
||||
chokidar: 4.0.3
|
||||
cli-table3: 0.6.5
|
||||
commander: 4.1.1
|
||||
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(lightningcss@1.32.0))
|
||||
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0)
|
||||
glob: 13.0.6
|
||||
node-emoji: 1.11.0
|
||||
ora: 5.4.1
|
||||
tsconfig-paths: 4.2.0
|
||||
tsconfig-paths-webpack-plugin: 4.2.0
|
||||
typescript: 5.9.3
|
||||
webpack: 5.106.0(lightningcss@1.32.0)
|
||||
webpack: 5.106.0
|
||||
webpack-node-externals: 3.0.0
|
||||
transitivePeerDependencies:
|
||||
- '@minify-html/node'
|
||||
@@ -8125,7 +8128,7 @@ snapshots:
|
||||
'@types/js-cookie': 3.0.6
|
||||
dayjs: 1.11.20
|
||||
intersection-observer: 0.12.2
|
||||
js-cookie: 3.0.5
|
||||
js-cookie: 3.0.7
|
||||
lodash: 4.18.1
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
@@ -8295,7 +8298,7 @@ snapshots:
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.2
|
||||
on-finished: 2.4.1
|
||||
qs: 6.15.1
|
||||
qs: 6.15.2
|
||||
raw-body: 3.0.2
|
||||
type-is: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
@@ -8733,7 +8736,7 @@ snapshots:
|
||||
once: 1.4.0
|
||||
parseurl: 1.3.3
|
||||
proxy-addr: 2.0.7
|
||||
qs: 6.15.1
|
||||
qs: 6.15.2
|
||||
range-parser: 1.2.1
|
||||
router: 2.2.0
|
||||
send: 1.2.1
|
||||
@@ -8804,7 +8807,7 @@ snapshots:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(lightningcss@1.32.0)):
|
||||
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0):
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
chalk: 4.1.2
|
||||
@@ -8819,7 +8822,7 @@ snapshots:
|
||||
semver: 7.8.0
|
||||
tapable: 2.3.3
|
||||
typescript: 5.9.3
|
||||
webpack: 5.106.0(lightningcss@1.32.0)
|
||||
webpack: 5.106.0
|
||||
|
||||
form-data@4.0.5:
|
||||
dependencies:
|
||||
@@ -9382,7 +9385,7 @@ snapshots:
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
js-cookie@3.0.5: {}
|
||||
js-cookie@3.0.7: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
@@ -9834,7 +9837,7 @@ snapshots:
|
||||
|
||||
pure-rand@7.0.1: {}
|
||||
|
||||
qs@6.15.1:
|
||||
qs@6.15.2:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
@@ -10294,7 +10297,7 @@ snapshots:
|
||||
formidable: 3.5.4
|
||||
methods: 1.1.2
|
||||
mime: 2.6.0
|
||||
qs: 6.15.1
|
||||
qs: 6.15.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -10330,15 +10333,13 @@ snapshots:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.11.0
|
||||
|
||||
terser-webpack-plugin@5.6.0(lightningcss@1.32.0)(webpack@5.106.0(lightningcss@1.32.0)):
|
||||
terser-webpack-plugin@5.6.0(webpack@5.106.0):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jest-worker: 27.5.1
|
||||
schema-utils: 4.3.3
|
||||
terser: 5.47.1
|
||||
webpack: 5.106.0(lightningcss@1.32.0)
|
||||
optionalDependencies:
|
||||
lightningcss: 1.32.0
|
||||
webpack: 5.106.0
|
||||
|
||||
terser@5.47.1:
|
||||
dependencies:
|
||||
@@ -10391,7 +10392,7 @@ snapshots:
|
||||
babel-jest: 30.4.1(@babel/core@7.29.0)
|
||||
jest-util: 30.4.1
|
||||
|
||||
ts-loader@9.5.7(typescript@6.0.3)(webpack@5.106.0(lightningcss@1.32.0)):
|
||||
ts-loader@9.5.7(typescript@6.0.3)(webpack@5.106.0):
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
enhanced-resolve: 5.21.3
|
||||
@@ -10399,7 +10400,7 @@ snapshots:
|
||||
semver: 7.8.0
|
||||
source-map: 0.7.6
|
||||
typescript: 6.0.3
|
||||
webpack: 5.106.0(lightningcss@1.32.0)
|
||||
webpack: 5.106.0
|
||||
|
||||
ts-node@10.9.2(@types/node@25.7.0)(typescript@6.0.3):
|
||||
dependencies:
|
||||
@@ -10588,7 +10589,7 @@ snapshots:
|
||||
|
||||
webpack-sources@3.4.1: {}
|
||||
|
||||
webpack@5.106.0(lightningcss@1.32.0):
|
||||
webpack@5.106.0:
|
||||
dependencies:
|
||||
'@types/eslint-scope': 3.7.7
|
||||
'@types/estree': 1.0.9
|
||||
@@ -10612,7 +10613,7 @@ snapshots:
|
||||
neo-async: 2.6.2
|
||||
schema-utils: 4.3.3
|
||||
tapable: 2.3.3
|
||||
terser-webpack-plugin: 5.6.0(lightningcss@1.32.0)(webpack@5.106.0(lightningcss@1.32.0))
|
||||
terser-webpack-plugin: 5.6.0(webpack@5.106.0)
|
||||
watchpack: 2.5.1
|
||||
webpack-sources: 3.4.1
|
||||
transitivePeerDependencies:
|
||||
|
||||
@@ -11,3 +11,25 @@ onlyBuiltDependencies:
|
||||
- sharp
|
||||
- sqlite3
|
||||
- unrs-resolver
|
||||
|
||||
# Husky and lint-staged shell out to pnpm without a TTY, so the interactive
|
||||
# "purge modules dir?" prompt errors out (ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY)
|
||||
# and aborts the commit. Skipping the prompt lets the hook proceed.
|
||||
confirmModulesPurge: false
|
||||
|
||||
# Pinned for security. Moved from package.json#pnpm.overrides — pnpm 11
|
||||
# no longer reads that field; settings live here now.
|
||||
overrides:
|
||||
picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
|
||||
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
|
||||
postcss@<8.5.10: '>=8.5.12'
|
||||
fast-xml-parser@<5.7.0: '>=5.7.2'
|
||||
fast-uri@<3.1.2: '>=3.1.2'
|
||||
fast-xml-builder@<1.2.0: '>=1.2.0'
|
||||
qs@>=6.11.1 <6.15.2: '>=6.15.2'
|
||||
js-cookie@<3.0.7: '>=3.0.7'
|
||||
|
||||
allowBuilds:
|
||||
'@nestjs/core': true
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
|
||||
Generated
+108
-89
@@ -35,7 +35,7 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8"
|
||||
dependencies = [
|
||||
"cipher 0.5.1",
|
||||
"cipher 0.5.2",
|
||||
"cpubits",
|
||||
"cpufeatures 0.3.0",
|
||||
]
|
||||
@@ -169,7 +169,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -180,7 +180,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -445,9 +445,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "av-scenechange"
|
||||
@@ -785,15 +785,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "built"
|
||||
version = "0.8.0"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64"
|
||||
checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "byte-unit"
|
||||
@@ -962,11 +962,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98db6aeaef0eeef2c1e3ce9a27b739218825dae116076352ac3777076aa22225"
|
||||
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
|
||||
dependencies = [
|
||||
"cipher 0.5.1",
|
||||
"cipher 0.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1103,11 +1103,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.1"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea"
|
||||
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"crypto-common 0.2.1",
|
||||
"crypto-common 0.2.2",
|
||||
"inout 0.2.2",
|
||||
]
|
||||
|
||||
@@ -1405,9 +1405,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
@@ -1679,7 +1679,7 @@ checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"const-oid 0.10.2",
|
||||
"crypto-common 0.2.1",
|
||||
"crypto-common 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1709,7 +1709,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1784,7 +1784,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.24.2"
|
||||
version = "0.24.3"
|
||||
dependencies = [
|
||||
"aes 0.9.0",
|
||||
"aes-gcm",
|
||||
@@ -1824,7 +1824,7 @@ dependencies = [
|
||||
"objc2-app-kit",
|
||||
"once_cell",
|
||||
"playwright",
|
||||
"quick-xml",
|
||||
"quick-xml 0.40.1",
|
||||
"rand 0.10.1",
|
||||
"regex-lite",
|
||||
"reqwest 0.13.3",
|
||||
@@ -1858,7 +1858,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tray-icon 0.24.0",
|
||||
@@ -1962,9 +1962,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
@@ -2099,7 +2099,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2213,9 +2213,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.28"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
@@ -3087,7 +3087,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.62.2",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3554,12 +3554,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kurbo"
|
||||
version = "0.13.0"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
|
||||
checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"euclid",
|
||||
"polycool",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
@@ -3914,9 +3915,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.19.1"
|
||||
version = "0.19.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb"
|
||||
checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
@@ -3931,7 +3932,7 @@ dependencies = [
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4038,9 +4039,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
@@ -4382,9 +4383,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.4"
|
||||
version = "5.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd"
|
||||
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
@@ -4394,9 +4395,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.79"
|
||||
version = "0.10.80"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542"
|
||||
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
@@ -4425,9 +4426,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.115"
|
||||
version = "0.9.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781"
|
||||
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -4468,7 +4469,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4660,18 +4661,18 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.12"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9"
|
||||
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.12"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389"
|
||||
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4742,7 +4743,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.14.0",
|
||||
"quick-xml",
|
||||
"quick-xml 0.39.4",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -4798,6 +4799,15 @@ dependencies = [
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polycool"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
@@ -5014,6 +5024,15 @@ name = "quick-xml"
|
||||
version = "0.39.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
@@ -5489,9 +5508,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d"
|
||||
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"thiserror 2.0.18",
|
||||
@@ -5564,7 +5583,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5854,9 +5873,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -6233,7 +6252,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6305,9 +6324,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.3"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36"
|
||||
checksum = "cdd578e94101503d97e2b286bbf8db2135035ca24b2ce4cbf3f9e2fb2bbf1eee"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"js-sys",
|
||||
@@ -6449,9 +6468,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.39.1"
|
||||
version = "0.39.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4deba334e1190ba7cb498327affa11e5ece10d26a30ab2f27fcf09504b8d8b6"
|
||||
checksum = "14311e7e9a03114cd4b65eedd54e8fed2945e17f08586ae97ef53bc0669f9581"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"memchr",
|
||||
@@ -6498,9 +6517,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tao"
|
||||
version = "0.35.2"
|
||||
version = "0.35.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4"
|
||||
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
@@ -6555,9 +6574,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.45"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
@@ -6572,9 +6591,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "tauri"
|
||||
version = "2.11.1"
|
||||
version = "2.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405"
|
||||
checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -6623,9 +6642,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-build"
|
||||
version = "2.6.1"
|
||||
version = "2.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007"
|
||||
checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
@@ -6644,9 +6663,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-codegen"
|
||||
version = "2.6.1"
|
||||
version = "2.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528"
|
||||
checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"brotli",
|
||||
@@ -6671,9 +6690,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-macros"
|
||||
version = "2.6.1"
|
||||
version = "2.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502"
|
||||
checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
@@ -6685,9 +6704,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.6.1"
|
||||
version = "2.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee"
|
||||
checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
@@ -6874,9 +6893,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.1"
|
||||
version = "2.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc"
|
||||
checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"dpi",
|
||||
@@ -6899,9 +6918,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime-wry"
|
||||
version = "2.11.1"
|
||||
version = "2.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0"
|
||||
checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
@@ -6925,9 +6944,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-utils"
|
||||
version = "2.9.1"
|
||||
version = "2.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec"
|
||||
checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brotli",
|
||||
@@ -6954,7 +6973,7 @@ dependencies = [
|
||||
"serde_with",
|
||||
"swift-rs",
|
||||
"thiserror 2.0.18",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid",
|
||||
@@ -6982,7 +7001,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7384,9 +7403,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.10"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bytes",
|
||||
@@ -7484,7 +7503,7 @@ dependencies = [
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7505,7 +7524,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7577,7 +7596,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8092,7 +8111,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quick-xml 0.39.4",
|
||||
"quote",
|
||||
]
|
||||
|
||||
@@ -8235,7 +8254,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8761,7 +8780,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9145,9 +9164,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.24.2"
|
||||
version = "0.24.3"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -100,12 +100,12 @@ playwright = { git = "https://github.com/zhom/playwright-rust", branch = "master
|
||||
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
|
||||
rusqlite = { version = "0.39", features = ["bundled"] }
|
||||
serde_yaml = "0.9"
|
||||
toml = "0.9"
|
||||
toml = "1.1"
|
||||
thiserror = "2.0"
|
||||
regex-lite = "0.1"
|
||||
tempfile = "3"
|
||||
maxminddb = "0.28"
|
||||
quick-xml = { version = "0.39", features = ["serialize"] }
|
||||
quick-xml = { version = "0.40", features = ["serialize"] }
|
||||
|
||||
# VPN support
|
||||
boringtun = "0.7"
|
||||
|
||||
@@ -87,6 +87,8 @@ pub struct UpdateProfileRequest {
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub extension_group_id: Option<String>,
|
||||
pub proxy_bypass_rules: Option<Vec<String>>,
|
||||
/// One of "Disabled", "Regular", "Encrypted".
|
||||
pub sync_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -215,6 +217,20 @@ struct OpenUrlRequest {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct ImportCookiesRequest {
|
||||
/// Raw cookie file content. Format is auto-detected: a JSON array
|
||||
/// (Puppeteer / EditThisCookie style) or a Netscape `cookies.txt`.
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct ImportCookiesResponse {
|
||||
cookies_imported: usize,
|
||||
cookies_replaced: usize,
|
||||
errors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
@@ -226,6 +242,7 @@ struct OpenUrlRequest {
|
||||
run_profile,
|
||||
open_url_in_profile,
|
||||
kill_profile,
|
||||
import_profile_cookies,
|
||||
get_groups,
|
||||
get_group,
|
||||
create_group,
|
||||
@@ -268,6 +285,8 @@ struct OpenUrlRequest {
|
||||
RunProfileResponse,
|
||||
RunProfileRequest,
|
||||
OpenUrlRequest,
|
||||
ImportCookiesRequest,
|
||||
ImportCookiesResponse,
|
||||
ProxySettings,
|
||||
)),
|
||||
tags(
|
||||
@@ -277,6 +296,7 @@ struct OpenUrlRequest {
|
||||
(name = "proxies", description = "Proxy management endpoints"),
|
||||
(name = "vpns", description = "VPN management endpoints"),
|
||||
(name = "browsers", description = "Browser management endpoints"),
|
||||
(name = "cookies", description = "Cookie management endpoints"),
|
||||
),
|
||||
modifiers(&SecurityAddon),
|
||||
)]
|
||||
@@ -363,6 +383,7 @@ impl ApiServer {
|
||||
.routes(routes!(run_profile))
|
||||
.routes(routes!(open_url_in_profile))
|
||||
.routes(routes!(kill_profile))
|
||||
.routes(routes!(import_profile_cookies))
|
||||
.routes(routes!(get_groups, create_group))
|
||||
.routes(routes!(get_group, update_group, delete_group))
|
||||
.routes(routes!(get_tags))
|
||||
@@ -397,10 +418,15 @@ impl ApiServer {
|
||||
.route("/events", get(ws_handler))
|
||||
.with_state(ws_state);
|
||||
|
||||
let api_for_v1 = api.clone();
|
||||
let app = Router::new()
|
||||
.merge(v1_routes)
|
||||
.nest("/ws", ws_routes)
|
||||
.route("/openapi.json", get(move || async move { Json(api) }))
|
||||
.route(
|
||||
"/v1/openapi.json",
|
||||
get(move || async move { Json(api_for_v1) }),
|
||||
)
|
||||
// Outermost layer: logs every request so customer reports show what
|
||||
// their automation is actually calling, what the response status was,
|
||||
// and how long it took. Never logs request bodies or auth headers.
|
||||
@@ -929,6 +955,15 @@ async fn update_profile(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sync_mode) = request.sync_mode {
|
||||
if crate::sync::set_profile_sync_mode(state.app_handle.clone(), id.clone(), sync_mode)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated profile
|
||||
get_profile(Path(id), State(state)).await
|
||||
}
|
||||
@@ -1818,6 +1853,77 @@ async fn kill_profile(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/profiles/{id}/cookies/import",
|
||||
params(
|
||||
("id" = String, Path, description = "Profile ID")
|
||||
),
|
||||
request_body = ImportCookiesRequest,
|
||||
responses(
|
||||
(status = 200, description = "Cookies imported successfully", body = ImportCookiesResponse),
|
||||
(status = 400, description = "Invalid cookie file or unsupported browser"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 409, description = "Browser is currently running"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "cookies"
|
||||
)]
|
||||
async fn import_profile_cookies(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<ImportCookiesRequest>,
|
||||
) -> Result<Json<ImportCookiesResponse>, StatusCode> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if !profiles.iter().any(|p| p.id.to_string() == id) {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
match crate::cookie_manager::CookieManager::import_cookies(
|
||||
&state.app_handle,
|
||||
&id,
|
||||
&request.content,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
|
||||
if profile.is_sync_enabled() {
|
||||
let pid = id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
scheduler.queue_profile_sync(pid).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Json(ImportCookiesResponse {
|
||||
cookies_imported: result.cookies_imported,
|
||||
cookies_replaced: result.cookies_replaced,
|
||||
errors: result.errors,
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_lowercase();
|
||||
if msg.contains("running") {
|
||||
Err(StatusCode::CONFLICT)
|
||||
} else if msg.contains("no valid cookies") || msg.contains("unsupported browser") {
|
||||
Err(StatusCode::BAD_REQUEST)
|
||||
} else {
|
||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// API Handler - Download Browser
|
||||
#[utoipa::path(
|
||||
post,
|
||||
|
||||
@@ -376,11 +376,12 @@ impl CamoufoxConfigBuilder {
|
||||
(config, target_os)
|
||||
};
|
||||
|
||||
// Add random window history length
|
||||
config.insert(
|
||||
"window.history.length".to_string(),
|
||||
serde_json::json!(rng.random_range(1..=5)),
|
||||
);
|
||||
// Note: we used to spoof `window.history.length` to a random value in
|
||||
// [1, 5] here. Newer Camoufox builds clamp the docShell session history
|
||||
// to this value, which disables the toolbar back/forward buttons when
|
||||
// the spoof rolls a small number. The fingerprint value drifts on every
|
||||
// user navigation anyway, so a constant spoof is detectable and not
|
||||
// worth the broken navigation UX.
|
||||
|
||||
// Add fonts
|
||||
if !self.custom_fonts_only {
|
||||
|
||||
@@ -222,10 +222,16 @@ impl CamoufoxManager {
|
||||
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?;
|
||||
|
||||
// Parse the fingerprint config JSON
|
||||
let fingerprint_config: HashMap<String, serde_json::Value> =
|
||||
let mut fingerprint_config: HashMap<String, serde_json::Value> =
|
||||
serde_json::from_str(&custom_config)
|
||||
.map_err(|e| format!("Failed to parse fingerprint config: {e}"))?;
|
||||
|
||||
// Strip `window.history.length` even when present in a previously-saved
|
||||
// fingerprint. Newer Camoufox clamps the docShell session history to the
|
||||
// spoofed value, which disables the toolbar back/forward buttons. See
|
||||
// the matching note in camoufox/config.rs.
|
||||
fingerprint_config.remove("window.history.length");
|
||||
|
||||
// Convert to environment variables using CAMOU_CONFIG chunking
|
||||
let env_vars = crate::camoufox::env_vars::config_to_env_vars(&fingerprint_config)
|
||||
.map_err(|e| format!("Failed to convert config to env vars: {e}"))?;
|
||||
@@ -287,7 +293,7 @@ impl CamoufoxManager {
|
||||
}
|
||||
}
|
||||
|
||||
let child = command
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn Camoufox process: {e}"))?;
|
||||
|
||||
@@ -296,6 +302,34 @@ impl CamoufoxManager {
|
||||
|
||||
log::info!("Camoufox launched with PID: {:?}", process_id);
|
||||
|
||||
// Watch the child so its exit status (signal / non-zero code) lands in
|
||||
// the log. Without this, all we see is "PID X is no longer running" via
|
||||
// the periodic sysinfo poll, with no clue why it died.
|
||||
let watch_profile_path = profile_path.to_string();
|
||||
tokio::spawn(async move {
|
||||
match child.wait().await {
|
||||
Ok(status) => {
|
||||
if status.success() {
|
||||
log::info!(
|
||||
"Camoufox PID {:?} for {} exited cleanly (status=0)",
|
||||
process_id,
|
||||
watch_profile_path
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
"Camoufox PID {:?} for {} exited abnormally: {}",
|
||||
process_id,
|
||||
watch_profile_path,
|
||||
status
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to await Camoufox PID {:?} exit: {}", process_id, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Store the instance
|
||||
let instance = CamoufoxInstance {
|
||||
id: instance_id.clone(),
|
||||
@@ -557,28 +591,28 @@ impl CamoufoxManager {
|
||||
|
||||
for (id, instance) in inner.instances.iter() {
|
||||
if let Some(process_id) = instance.process_id {
|
||||
// Check if the process is still alive
|
||||
if !self.is_server_running(process_id).await {
|
||||
// Process is dead
|
||||
// Camoufox instance is no longer running
|
||||
log::info!(
|
||||
"Camoufox instance {} (PID {}) is no longer running; profile_path={:?}",
|
||||
id,
|
||||
process_id,
|
||||
instance.profile_path
|
||||
);
|
||||
dead_instances.push(id.clone());
|
||||
instances_to_remove.push(id.clone());
|
||||
}
|
||||
} else {
|
||||
// No process_id means it's likely a dead instance
|
||||
// Camoufox instance has no PID, marking as dead
|
||||
log::info!("Camoufox instance {} has no PID, marking as dead", id);
|
||||
dead_instances.push(id.clone());
|
||||
instances_to_remove.push(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove dead instances
|
||||
if !instances_to_remove.is_empty() {
|
||||
let mut inner = self.inner.lock().await;
|
||||
for id in &instances_to_remove {
|
||||
inner.instances.remove(id);
|
||||
// Removed dead Camoufox instance
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,54 +696,83 @@ impl CamoufoxManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Write explicit proxy prefs to user.js so Firefox always uses the local
|
||||
// donut-proxy and never falls back to stale proxy settings baked into prefs.js
|
||||
// from a previous session. user.js values override prefs.js on every launch.
|
||||
if let Some(proxy_str) = &config.proxy {
|
||||
// Patch user.js with Camoufox-specific overrides on every launch. This
|
||||
// always runs (not gated on the proxy being set) because Camoufox's
|
||||
// bundled camoufox.cfg ships defaults that break basic browser features
|
||||
// and we need to override them per-profile.
|
||||
{
|
||||
let user_js_path = profile_path.join("user.js");
|
||||
let mut prefs = String::new();
|
||||
|
||||
// Preserve existing user.js content (ephemeral prefs, etc.)
|
||||
// Preserve existing user.js lines, but strip any keys we're about to
|
||||
// re-emit so they never duplicate.
|
||||
let managed_keys = [
|
||||
"network.proxy.",
|
||||
"xpinstall.signatures.required",
|
||||
"extensions.startupScanScopes",
|
||||
"browser.sessionhistory.max_entries",
|
||||
"browser.sessionhistory.max_total_viewers",
|
||||
];
|
||||
if let Ok(existing) = std::fs::read_to_string(&user_js_path) {
|
||||
// Strip old proxy prefs so we don't duplicate
|
||||
for line in existing.lines() {
|
||||
if !line.contains("network.proxy.") {
|
||||
if !managed_keys.iter().any(|k| line.contains(k)) {
|
||||
prefs.push_str(line);
|
||||
prefs.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(parsed) = url::Url::parse(proxy_str) {
|
||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||
let port = parsed.port().unwrap_or(8080);
|
||||
let scheme = parsed.scheme();
|
||||
// Camoufox's bundled camoufox.cfg sets these to 0, which makes
|
||||
// docShell remember zero prior pages and leaves the toolbar
|
||||
// back/forward buttons permanently disabled no matter how much
|
||||
// the user navigates. Restore Firefox defaults.
|
||||
prefs.push_str(
|
||||
"user_pref(\"browser.sessionhistory.max_entries\", 50);\n\
|
||||
user_pref(\"browser.sessionhistory.max_total_viewers\", -1);\n",
|
||||
);
|
||||
|
||||
if scheme == "socks5" || scheme == "socks4" {
|
||||
prefs.push_str(&format!(
|
||||
"user_pref(\"network.proxy.type\", 1);\n\
|
||||
user_pref(\"network.proxy.socks\", \"{host}\");\n\
|
||||
user_pref(\"network.proxy.socks_port\", {port});\n\
|
||||
user_pref(\"network.proxy.socks_version\", {});\n\
|
||||
user_pref(\"network.proxy.socks_remote_dns\", true);\n",
|
||||
if scheme == "socks5" { 5 } else { 4 }
|
||||
));
|
||||
} else {
|
||||
// HTTP/HTTPS proxy
|
||||
prefs.push_str(&format!(
|
||||
"user_pref(\"network.proxy.type\", 1);\n\
|
||||
user_pref(\"network.proxy.http\", \"{host}\");\n\
|
||||
user_pref(\"network.proxy.http_port\", {port});\n\
|
||||
user_pref(\"network.proxy.ssl\", \"{host}\");\n\
|
||||
user_pref(\"network.proxy.ssl_port\", {port});\n\
|
||||
user_pref(\"network.proxy.no_proxies_on\", \"\");\n"
|
||||
));
|
||||
}
|
||||
// Required for sideloaded extensions:
|
||||
// - signatures.required=false accepts unsigned .xpi (Camoufox is built
|
||||
// without MOZ_REQUIRE_SIGNING so this is honored).
|
||||
// - startupScanScopes=1 rescans SCOPE_PROFILE on each launch so newly
|
||||
// dropped .xpi files in <profile>/extensions/ get registered.
|
||||
prefs.push_str(
|
||||
"user_pref(\"xpinstall.signatures.required\", false);\n\
|
||||
user_pref(\"extensions.startupScanScopes\", 1);\n",
|
||||
);
|
||||
|
||||
if let Err(e) = std::fs::write(&user_js_path, prefs) {
|
||||
log::warn!("Failed to write proxy prefs to user.js: {e}");
|
||||
if let Some(proxy_str) = &config.proxy {
|
||||
if let Ok(parsed) = url::Url::parse(proxy_str) {
|
||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||
let port = parsed.port().unwrap_or(8080);
|
||||
let scheme = parsed.scheme();
|
||||
|
||||
if scheme == "socks5" || scheme == "socks4" {
|
||||
prefs.push_str(&format!(
|
||||
"user_pref(\"network.proxy.type\", 1);\n\
|
||||
user_pref(\"network.proxy.socks\", \"{host}\");\n\
|
||||
user_pref(\"network.proxy.socks_port\", {port});\n\
|
||||
user_pref(\"network.proxy.socks_version\", {});\n\
|
||||
user_pref(\"network.proxy.socks_remote_dns\", true);\n",
|
||||
if scheme == "socks5" { 5 } else { 4 }
|
||||
));
|
||||
} else {
|
||||
// HTTP/HTTPS proxy
|
||||
prefs.push_str(&format!(
|
||||
"user_pref(\"network.proxy.type\", 1);\n\
|
||||
user_pref(\"network.proxy.http\", \"{host}\");\n\
|
||||
user_pref(\"network.proxy.http_port\", {port});\n\
|
||||
user_pref(\"network.proxy.ssl\", \"{host}\");\n\
|
||||
user_pref(\"network.proxy.ssl_port\", {port});\n\
|
||||
user_pref(\"network.proxy.no_proxies_on\", \"\");\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = std::fs::write(&user_js_path, prefs) {
|
||||
log::warn!("Failed to write user.js: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
self
|
||||
|
||||
@@ -27,6 +27,11 @@ pub struct Extension {
|
||||
pub author: Option<String>,
|
||||
#[serde(default)]
|
||||
pub homepage_url: Option<String>,
|
||||
/// Firefox extension ID from `browser_specific_settings.gecko.id` (or
|
||||
/// `applications.gecko.id` in old manifests). Firefox refuses to load a
|
||||
/// sideloaded .xpi unless the filename matches this value.
|
||||
#[serde(default)]
|
||||
pub gecko_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -157,6 +162,32 @@ fn extract_manifest_metadata(
|
||||
(name, version, description, author, homepage_url)
|
||||
}
|
||||
|
||||
/// Read `browser_specific_settings.gecko.id` (or the legacy
|
||||
/// `applications.gecko.id`) from the extension's manifest.json. Firefox uses
|
||||
/// this value as the canonical add-on ID; sideloaded .xpi files must be named
|
||||
/// `<gecko_id>.xpi` to be picked up.
|
||||
fn extract_gecko_id(file_data: &[u8], file_type: &str) -> Option<String> {
|
||||
let zip_start = if file_type == "crx" {
|
||||
find_zip_start(file_data)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let cursor = std::io::Cursor::new(&file_data[zip_start..]);
|
||||
let mut archive = zip::ZipArchive::new(cursor).ok()?;
|
||||
let mut manifest_content = String::new();
|
||||
std::io::Read::read_to_string(
|
||||
&mut archive.by_name("manifest.json").ok()?,
|
||||
&mut manifest_content,
|
||||
)
|
||||
.ok()?;
|
||||
let manifest: serde_json::Value = serde_json::from_str(&manifest_content).ok()?;
|
||||
manifest
|
||||
.pointer("/browser_specific_settings/gecko/id")
|
||||
.or_else(|| manifest.pointer("/applications/gecko/id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn extract_icon_from_archive(file_data: &[u8], file_type: &str) -> Option<(Vec<u8>, String)> {
|
||||
let zip_start = if file_type == "crx" {
|
||||
find_zip_start(file_data)
|
||||
@@ -285,6 +316,7 @@ impl ExtensionManager {
|
||||
name
|
||||
};
|
||||
|
||||
let gecko_id = extract_gecko_id(&file_data, &file_type);
|
||||
let ext = Extension {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: final_name,
|
||||
@@ -299,6 +331,7 @@ impl ExtensionManager {
|
||||
description,
|
||||
author,
|
||||
homepage_url,
|
||||
gecko_id,
|
||||
};
|
||||
|
||||
let file_dir = self.get_file_dir(&ext.id);
|
||||
@@ -415,6 +448,7 @@ impl ExtensionManager {
|
||||
ext.name = mn;
|
||||
}
|
||||
}
|
||||
ext.gecko_id = extract_gecko_id(&data, &new_file_type);
|
||||
|
||||
if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&data, &new_file_type) {
|
||||
let icon_path = self.get_extension_dir(id).join(format!("icon.{icon_ext}"));
|
||||
@@ -893,24 +927,33 @@ impl ExtensionManager {
|
||||
continue;
|
||||
}
|
||||
let src_file = self.get_file_dir(ext_id).join(&ext.file_name);
|
||||
if src_file.exists() {
|
||||
// Firefox expects .xpi files in extensions dir
|
||||
let dest_name = if ext.file_type == "zip" {
|
||||
format!(
|
||||
"{}.xpi",
|
||||
ext
|
||||
.file_name
|
||||
.rsplit('.')
|
||||
.next_back()
|
||||
.unwrap_or(&ext.file_name)
|
||||
)
|
||||
} else {
|
||||
ext.file_name.clone()
|
||||
};
|
||||
let dest = extensions_dir.join(&dest_name);
|
||||
fs::copy(&src_file, &dest)?;
|
||||
extension_paths.push(dest.to_string_lossy().to_string());
|
||||
if !src_file.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Firefox/Camoufox only loads sideloaded .xpi files whose filename
|
||||
// matches `browser_specific_settings.gecko.id` from the manifest.
|
||||
// Prefer the cached value; fall back to reading the manifest now
|
||||
// for extensions added before the field existed.
|
||||
let gecko_id = if let Some(ref id) = ext.gecko_id {
|
||||
Some(id.clone())
|
||||
} else if let Ok(data) = fs::read(&src_file) {
|
||||
extract_gecko_id(&data, &ext.file_type)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let Some(gecko_id) = gecko_id else {
|
||||
log::warn!(
|
||||
"Skipping Firefox extension '{}': could not determine gecko id from manifest.json",
|
||||
ext.name
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let dest = extensions_dir.join(format!("{gecko_id}.xpi"));
|
||||
fs::copy(&src_file, &dest)?;
|
||||
extension_paths.push(dest.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1022,30 +1065,49 @@ impl ExtensionManager {
|
||||
}
|
||||
}
|
||||
|
||||
if ext.version.is_none() && ext.description.is_none() {
|
||||
let needs_meta_backfill = ext.version.is_none() && ext.description.is_none();
|
||||
let needs_gecko_backfill =
|
||||
ext.gecko_id.is_none() && ext.browser_compatibility.iter().any(|b| b == "firefox");
|
||||
|
||||
if needs_meta_backfill || needs_gecko_backfill {
|
||||
let file_path = file_dir.join(&ext.file_name);
|
||||
if let Ok(file_data) = fs::read(&file_path) {
|
||||
let (manifest_name, version, description, author, homepage_url) =
|
||||
extract_manifest_metadata(&file_data, &ext.file_type);
|
||||
if version.is_some()
|
||||
|| description.is_some()
|
||||
|| author.is_some()
|
||||
|| homepage_url.is_some()
|
||||
|| manifest_name.is_some()
|
||||
{
|
||||
let mut updated_ext = ext.clone();
|
||||
if let Some(v) = version {
|
||||
updated_ext.version = Some(v);
|
||||
let mut updated_ext = ext.clone();
|
||||
let mut changed = false;
|
||||
|
||||
if needs_meta_backfill {
|
||||
let (manifest_name, version, description, author, homepage_url) =
|
||||
extract_manifest_metadata(&file_data, &ext.file_type);
|
||||
if version.is_some()
|
||||
|| description.is_some()
|
||||
|| author.is_some()
|
||||
|| homepage_url.is_some()
|
||||
|| manifest_name.is_some()
|
||||
{
|
||||
if let Some(v) = version {
|
||||
updated_ext.version = Some(v);
|
||||
}
|
||||
if let Some(d) = description {
|
||||
updated_ext.description = Some(d);
|
||||
}
|
||||
if let Some(a) = author {
|
||||
updated_ext.author = Some(a);
|
||||
}
|
||||
if let Some(h) = homepage_url {
|
||||
updated_ext.homepage_url = Some(h);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if let Some(d) = description {
|
||||
updated_ext.description = Some(d);
|
||||
}
|
||||
if let Some(a) = author {
|
||||
updated_ext.author = Some(a);
|
||||
}
|
||||
if let Some(h) = homepage_url {
|
||||
updated_ext.homepage_url = Some(h);
|
||||
}
|
||||
|
||||
if needs_gecko_backfill {
|
||||
if let Some(gid) = extract_gecko_id(&file_data, &ext.file_type) {
|
||||
updated_ext.gecko_id = Some(gid);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
let metadata_path = self.get_metadata_path(&ext.id);
|
||||
if let Ok(json) = serde_json::to_string_pretty(&updated_ext) {
|
||||
let _ = fs::write(metadata_path, json);
|
||||
|
||||
@@ -99,7 +99,7 @@ use settings_manager::{
|
||||
};
|
||||
|
||||
use sync::{
|
||||
check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities,
|
||||
cancel_profile_sync, check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities,
|
||||
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile,
|
||||
is_vpn_in_use_by_synced_profile, request_profile_sync, rollover_encryption_for_all_entities,
|
||||
set_e2e_password, set_extension_group_sync_enabled, set_extension_sync_enabled,
|
||||
@@ -2057,6 +2057,7 @@ pub fn run() {
|
||||
get_sync_settings,
|
||||
save_sync_settings,
|
||||
set_profile_sync_mode,
|
||||
cancel_profile_sync,
|
||||
request_profile_sync,
|
||||
set_proxy_sync_enabled,
|
||||
set_group_sync_enabled,
|
||||
|
||||
+500
-1
@@ -33,6 +33,48 @@ pub struct McpTool {
|
||||
pub input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
/// JavaScript executed in the target page to enumerate visible interactive
|
||||
/// elements. Returns a JSON string `{elements, count, truncated}` where
|
||||
/// `elements` is the newline-joined labeled list. Live references are stashed
|
||||
/// on `window.__donut_interactive` so subsequent `click_by_index` /
|
||||
/// `type_by_index` calls can resolve `index → Element` without round-tripping
|
||||
/// a selector. `__MAX_CHARS__` is substituted at call time.
|
||||
const INTERACTIVE_ELEMENTS_JS: &str = r#"(() => {
|
||||
const SELECTORS = 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="tab"], [role="menuitem"], [role="combobox"], [role="option"], [contenteditable=""], [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
|
||||
const ATTRS = ['type','name','id','role','aria-label','aria-checked','aria-expanded','placeholder','title','value','href','alt'];
|
||||
const MAX_CHARS = __MAX_CHARS__;
|
||||
const interactive = [];
|
||||
const lines = [];
|
||||
let truncated = false;
|
||||
let total = 0;
|
||||
const nodes = document.querySelectorAll(SELECTORS);
|
||||
for (const el of nodes) {
|
||||
if (el.disabled) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width <= 0 || r.height <= 0) continue;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') continue;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const parts = [];
|
||||
for (const a of ATTRS) {
|
||||
const v = el.getAttribute(a);
|
||||
if (v) parts.push(a + '="' + String(v).slice(0,100).replace(/"/g,'\\"') + '"');
|
||||
}
|
||||
let text = '';
|
||||
if (!['INPUT','TEXTAREA','SELECT'].includes(el.tagName)) {
|
||||
text = (el.innerText || el.textContent || '').trim().replace(/\s+/g,' ').slice(0,100);
|
||||
}
|
||||
const idx = interactive.length;
|
||||
const line = '[' + idx + ']<' + tag + (parts.length ? ' ' + parts.join(' ') : '') + '>' + text + '</' + tag + '>';
|
||||
if (total + line.length + 1 > MAX_CHARS) { truncated = true; break; }
|
||||
total += line.length + 1;
|
||||
interactive.push(el);
|
||||
lines.push(line);
|
||||
}
|
||||
window.__donut_interactive = interactive;
|
||||
return JSON.stringify({ elements: lines.join('\n'), count: interactive.length, truncated: truncated });
|
||||
})()"#;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct McpRequest {
|
||||
@@ -1103,6 +1145,25 @@ impl McpServer {
|
||||
"required": ["profile_id"]
|
||||
}),
|
||||
},
|
||||
// Cookie management tools
|
||||
McpTool {
|
||||
name: "import_profile_cookies".to_string(),
|
||||
description: "Import cookies into a Wayfern or Camoufox profile from a JSON array (Puppeteer / EditThisCookie format) or a Netscape cookies.txt. Format is auto-detected. The browser must not be running.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the target profile"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Raw cookie file content (JSON array or Netscape cookies.txt)"
|
||||
}
|
||||
},
|
||||
"required": ["profile_id", "content"]
|
||||
}),
|
||||
},
|
||||
// Team lock tools
|
||||
McpTool {
|
||||
name: "get_team_locks".to_string(),
|
||||
@@ -1354,6 +1415,76 @@ impl McpServer {
|
||||
"required": ["profile_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "get_interactive_elements".to_string(),
|
||||
description: "Enumerate visible interactive elements on the page (buttons, links, inputs, etc.) as a compact indexed list. The returned indices are stable for the current page and can be used with click_by_index and type_by_index instead of guessing CSS selectors. Call this before click_by_index / type_by_index, and re-call after any navigation or major DOM change. Far cheaper in tokens than get_page_content for agentic browsing.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the running profile"
|
||||
},
|
||||
"max_chars": {
|
||||
"type": "integer",
|
||||
"description": "Cap on the serialized output length (default: 40000). The response carries a `truncated` flag if the list was cut off — narrow the viewport or scroll if you need elements past the cutoff."
|
||||
}
|
||||
},
|
||||
"required": ["profile_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "click_by_index".to_string(),
|
||||
description: "Click the element at the given index from the last get_interactive_elements call. Indices are valid until the next navigation. If the click triggers navigation, waits for the new page to load before returning.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the running profile"
|
||||
},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"description": "Zero-based index from the last get_interactive_elements response"
|
||||
}
|
||||
},
|
||||
"required": ["profile_id", "index"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "type_by_index".to_string(),
|
||||
description: "Focus the element at the given index from the last get_interactive_elements call and type text into it. Same human-like-typing defaults as type_text; only set instant=true when you're sure the target lacks bot detection.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the running profile"
|
||||
},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"description": "Zero-based index from the last get_interactive_elements response"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Text to type into the element"
|
||||
},
|
||||
"clear_first": {
|
||||
"type": "boolean",
|
||||
"description": "Clear the input before typing (default: true)"
|
||||
},
|
||||
"instant": {
|
||||
"type": "boolean",
|
||||
"description": "Paste all text at once instead of human typing. WARNING: only use on targets without bot detection."
|
||||
},
|
||||
"wpm": {
|
||||
"type": "number",
|
||||
"description": "Target words per minute for human typing (default: 80)"
|
||||
}
|
||||
},
|
||||
"required": ["profile_id", "index", "text"]
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1562,6 +1693,8 @@ impl McpServer {
|
||||
.handle_assign_extension_group_to_profile(arguments)
|
||||
.await
|
||||
}
|
||||
// Cookie management
|
||||
"import_profile_cookies" => self.handle_import_profile_cookies(arguments).await,
|
||||
// Team lock tools
|
||||
"get_team_locks" => self.handle_get_team_locks().await,
|
||||
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
|
||||
@@ -1602,6 +1735,18 @@ impl McpServer {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_get_page_info(arguments).await
|
||||
}
|
||||
"get_interactive_elements" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_get_interactive_elements(arguments).await
|
||||
}
|
||||
"click_by_index" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_click_by_index(arguments).await
|
||||
}
|
||||
"type_by_index" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_type_by_index(arguments).await
|
||||
}
|
||||
_ => Err(McpError {
|
||||
code: -32602,
|
||||
message: format!("Unknown tool: {tool_name}"),
|
||||
@@ -2731,6 +2876,74 @@ impl McpServer {
|
||||
}))
|
||||
}
|
||||
|
||||
// Cookie management handlers
|
||||
async fn handle_import_profile_cookies(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing profile_id".to_string(),
|
||||
})?;
|
||||
|
||||
let content = arguments
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing content".to_string(),
|
||||
})?;
|
||||
|
||||
let app_handle = {
|
||||
let inner = self.inner.lock().await;
|
||||
inner
|
||||
.app_handle
|
||||
.as_ref()
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32000,
|
||||
message: "MCP server not properly initialized".to_string(),
|
||||
})?
|
||||
.clone()
|
||||
};
|
||||
|
||||
let result =
|
||||
crate::cookie_manager::CookieManager::import_cookies(&app_handle, profile_id, content)
|
||||
.await
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to import cookies: {e}"),
|
||||
})?;
|
||||
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
let profile_manager = crate::profile::manager::ProfileManager::instance();
|
||||
if let Ok(profiles) = profile_manager.list_profiles() {
|
||||
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) {
|
||||
if profile.is_sync_enabled() {
|
||||
let pid = profile_id.to_string();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
scheduler.queue_profile_sync(pid).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!(
|
||||
"Import complete: {} imported, {} replaced, {} parse error(s)",
|
||||
result.cookies_imported,
|
||||
result.cookies_replaced,
|
||||
result.errors.len()
|
||||
)
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
// VPN management handlers
|
||||
async fn handle_import_vpn(
|
||||
&self,
|
||||
@@ -4263,6 +4476,11 @@ impl McpServer {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("text");
|
||||
let selector = arguments.get("selector").and_then(|v| v.as_str());
|
||||
let max_chars = arguments
|
||||
.get("max_chars")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as usize)
|
||||
.unwrap_or(40_000);
|
||||
|
||||
let profile = self.get_running_profile(profile_id)?;
|
||||
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
|
||||
@@ -4310,10 +4528,28 @@ impl McpServer {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Cap output so a 500 KB DOM dump doesn't blow out the agent's context.
|
||||
// Slice on character boundaries (chars().take().collect()) rather than
|
||||
// byte indices, since the latter would panic on multi-byte boundaries.
|
||||
let total_chars = content.chars().count();
|
||||
let (text, truncated) = if total_chars > max_chars {
|
||||
(content.chars().take(max_chars).collect::<String>(), true)
|
||||
} else {
|
||||
(content.to_string(), false)
|
||||
};
|
||||
|
||||
let payload = if truncated {
|
||||
format!(
|
||||
"{text}\n\n[truncated: showing {max_chars} of {total_chars} chars — call with a larger max_chars or use get_interactive_elements for an indexed view]"
|
||||
)
|
||||
} else {
|
||||
text
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": content
|
||||
"text": payload
|
||||
}]
|
||||
}))
|
||||
}
|
||||
@@ -4361,6 +4597,267 @@ impl McpServer {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_get_interactive_elements(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing profile_id".to_string(),
|
||||
})?;
|
||||
let max_chars = arguments
|
||||
.get("max_chars")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as usize)
|
||||
.unwrap_or(40_000);
|
||||
|
||||
let profile = self.get_running_profile(profile_id)?;
|
||||
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
|
||||
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
|
||||
|
||||
// Walk the DOM for visible, non-disabled interactive elements, label them
|
||||
// with a zero-based index, and cache the live references on
|
||||
// `window.__donut_interactive` so click_by_index / type_by_index can
|
||||
// resolve the index → Element without round-tripping a selector.
|
||||
let js = INTERACTIVE_ELEMENTS_JS.replace("__MAX_CHARS__", &max_chars.to_string());
|
||||
|
||||
let result = self
|
||||
.send_cdp(
|
||||
&ws_url,
|
||||
"Runtime.evaluate",
|
||||
serde_json::json!({
|
||||
"expression": js,
|
||||
"returnByValue": true,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(exception) = result.get("exceptionDetails") {
|
||||
let msg = exception
|
||||
.get("exception")
|
||||
.and_then(|e| e.get("description"))
|
||||
.or_else(|| exception.get("text"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Enumeration failed");
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: msg.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let payload_str = result
|
||||
.get("result")
|
||||
.and_then(|r| r.get("value"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("{}");
|
||||
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_str(payload_str).unwrap_or(serde_json::json!({}));
|
||||
let elements = payload
|
||||
.get("elements")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let count = payload.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let truncated = payload
|
||||
.get("truncated")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let header = if truncated {
|
||||
format!("{count} interactive elements (truncated at {max_chars} chars — re-call with a larger max_chars or scroll the page):")
|
||||
} else {
|
||||
format!("{count} interactive elements:")
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("{header}\n{elements}")
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_click_by_index(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing profile_id".to_string(),
|
||||
})?;
|
||||
let index = arguments
|
||||
.get("index")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing index".to_string(),
|
||||
})?;
|
||||
|
||||
let profile = self.get_running_profile(profile_id)?;
|
||||
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
|
||||
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
|
||||
|
||||
let js = format!(
|
||||
r#"(() => {{
|
||||
const arr = window.__donut_interactive;
|
||||
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
|
||||
const el = arr[{index}];
|
||||
el.scrollIntoView({{block: 'center'}});
|
||||
el.click();
|
||||
return true;
|
||||
}})()"#
|
||||
);
|
||||
|
||||
let result = self
|
||||
.send_cdp_and_wait_for_load(
|
||||
&ws_url,
|
||||
"Runtime.evaluate",
|
||||
serde_json::json!({
|
||||
"expression": js,
|
||||
"returnByValue": true,
|
||||
}),
|
||||
10,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(exception) = result.get("exceptionDetails") {
|
||||
let msg = exception
|
||||
.get("exception")
|
||||
.and_then(|e| e.get("description"))
|
||||
.or_else(|| exception.get("text"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Click failed");
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: msg.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Clicked element at index {index}")
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_type_by_index(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing profile_id".to_string(),
|
||||
})?;
|
||||
let index = arguments
|
||||
.get("index")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing index".to_string(),
|
||||
})?;
|
||||
let text = arguments
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing text".to_string(),
|
||||
})?;
|
||||
let clear_first = arguments
|
||||
.get("clear_first")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
let instant = arguments
|
||||
.get("instant")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let wpm = arguments.get("wpm").and_then(|v| v.as_f64());
|
||||
|
||||
let profile = self.get_running_profile(profile_id)?;
|
||||
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
|
||||
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
|
||||
|
||||
// Mirrors handle_type_text's focus step but resolves the element via the
|
||||
// cached index instead of a CSS selector.
|
||||
let focus_js = if clear_first {
|
||||
format!(
|
||||
r#"(() => {{
|
||||
const arr = window.__donut_interactive;
|
||||
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
|
||||
const el = arr[{index}];
|
||||
el.scrollIntoView({{block: 'center'}});
|
||||
el.focus();
|
||||
el.value = '';
|
||||
el.dispatchEvent(new Event('input', {{bubbles: true}}));
|
||||
return true;
|
||||
}})()"#
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r#"(() => {{
|
||||
const arr = window.__donut_interactive;
|
||||
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
|
||||
const el = arr[{index}];
|
||||
el.scrollIntoView({{block: 'center'}});
|
||||
el.focus();
|
||||
return true;
|
||||
}})()"#
|
||||
)
|
||||
};
|
||||
|
||||
let focus_result = self
|
||||
.send_cdp(
|
||||
&ws_url,
|
||||
"Runtime.evaluate",
|
||||
serde_json::json!({
|
||||
"expression": focus_js,
|
||||
"returnByValue": true,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(exception) = focus_result.get("exceptionDetails") {
|
||||
let msg = exception
|
||||
.get("exception")
|
||||
.and_then(|e| e.get("description"))
|
||||
.or_else(|| exception.get("text"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Focus failed");
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: msg.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if instant {
|
||||
self
|
||||
.send_cdp(
|
||||
&ws_url,
|
||||
"Input.insertText",
|
||||
serde_json::json!({ "text": text }),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
self.send_human_keystrokes(&ws_url, text, wpm).await?;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Typed text into element at index {index}")
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Synchronizer handlers ---
|
||||
|
||||
async fn handle_start_sync_session(
|
||||
@@ -4560,6 +5057,8 @@ mod tests {
|
||||
assert!(tool_names.contains(&"delete_extension"));
|
||||
assert!(tool_names.contains(&"delete_extension_group"));
|
||||
assert!(tool_names.contains(&"assign_extension_group_to_profile"));
|
||||
// Cookie tools
|
||||
assert!(tool_names.contains(&"import_profile_cookies"));
|
||||
// Team lock tools
|
||||
assert!(tool_names.contains(&"get_team_locks"));
|
||||
assert!(tool_names.contains(&"get_team_lock_status"));
|
||||
|
||||
@@ -1799,10 +1799,17 @@ impl ProfileManager {
|
||||
"user_pref(\"startup.homepage_welcome_url\", \"\");".to_string(),
|
||||
"user_pref(\"startup.homepage_welcome_url.additional\", \"\");".to_string(),
|
||||
"user_pref(\"startup.homepage_override_url\", \"\");".to_string(),
|
||||
// Keep extension updates enabled and allow sideloaded extensions
|
||||
// Keep extension updates enabled and allow sideloaded extensions.
|
||||
// - autoDisableScopes=0: profile-installed extensions are enabled by default.
|
||||
// - startupScanScopes=1: rescan SCOPE_PROFILE on each launch so freshly
|
||||
// dropped .xpi files in <profile>/extensions/ get registered.
|
||||
// - signatures.required=false: accept unsigned/dev .xpi files. Camoufox
|
||||
// is built without MOZ_REQUIRE_SIGNING so this is honored.
|
||||
"user_pref(\"extensions.update.enabled\", true);".to_string(),
|
||||
"user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(),
|
||||
"user_pref(\"extensions.autoDisableScopes\", 0);".to_string(),
|
||||
"user_pref(\"extensions.startupScanScopes\", 1);".to_string(),
|
||||
"user_pref(\"xpinstall.signatures.required\", false);".to_string(),
|
||||
// Completely disable browser update checking
|
||||
"user_pref(\"app.update.enabled\", false);".to_string(),
|
||||
"user_pref(\"app.update.auto\", false);".to_string(),
|
||||
|
||||
@@ -52,7 +52,7 @@ pub struct AppSettings {
|
||||
#[serde(default)]
|
||||
pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt
|
||||
#[serde(default)]
|
||||
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ru", or None for system default
|
||||
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default
|
||||
#[serde(default)]
|
||||
pub window_resize_warning_dismissed: bool,
|
||||
#[serde(default)]
|
||||
|
||||
+241
-169
@@ -10,11 +10,48 @@ use chrono::{DateTime, Utc};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::Instant;
|
||||
use tokio::sync::{Mutex as TokioMutex, Semaphore};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref SYNC_CANCEL_FLAGS: StdMutex<HashMap<String, Arc<AtomicBool>>> =
|
||||
StdMutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
fn register_sync_cancel(profile_id: &str) -> Arc<AtomicBool> {
|
||||
let mut map = SYNC_CANCEL_FLAGS.lock().unwrap();
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
map.insert(profile_id.to_string(), flag.clone());
|
||||
flag
|
||||
}
|
||||
|
||||
fn clear_sync_cancel(profile_id: &str) {
|
||||
SYNC_CANCEL_FLAGS.lock().unwrap().remove(profile_id);
|
||||
}
|
||||
|
||||
pub fn request_sync_cancel(profile_id: &str) -> bool {
|
||||
if let Some(flag) = SYNC_CANCEL_FLAGS.lock().unwrap().get(profile_id) {
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
struct SyncCancelGuard(String);
|
||||
impl Drop for SyncCancelGuard {
|
||||
fn drop(&mut self) {
|
||||
clear_sync_cancel(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_profile_sync(profile_id: String) -> Result<bool, String> {
|
||||
Ok(request_sync_cancel(&profile_id))
|
||||
}
|
||||
|
||||
/// Upload/download concurrency limit
|
||||
const SYNC_CONCURRENCY: usize = 32;
|
||||
|
||||
@@ -391,6 +428,9 @@ impl SyncEngine {
|
||||
let profile_dir = profiles_dir.join(profile.id.to_string());
|
||||
let profile_id = profile.id.to_string();
|
||||
|
||||
let cancel_flag = register_sync_cancel(&profile_id);
|
||||
let _cancel_guard = SyncCancelGuard(profile_id.clone());
|
||||
|
||||
// Determine team key prefix for team profiles
|
||||
let key_prefix = Self::get_team_key_prefix(profile).await;
|
||||
|
||||
@@ -514,10 +554,16 @@ impl SyncEngine {
|
||||
&diff.files_to_upload,
|
||||
encryption_key.as_ref(),
|
||||
&key_prefix,
|
||||
&cancel_flag,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if cancel_flag.load(Ordering::Relaxed) {
|
||||
log::info!("Sync cancelled for profile {} after uploads", profile_id);
|
||||
return Err(SyncError::Cancelled);
|
||||
}
|
||||
|
||||
// Perform downloads
|
||||
if !diff.files_to_download.is_empty() {
|
||||
self
|
||||
@@ -529,10 +575,16 @@ impl SyncEngine {
|
||||
&diff.files_to_download,
|
||||
encryption_key.as_ref(),
|
||||
&key_prefix,
|
||||
&cancel_flag,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if cancel_flag.load(Ordering::Relaxed) {
|
||||
log::info!("Sync cancelled for profile {} after downloads", profile_id);
|
||||
return Err(SyncError::Cancelled);
|
||||
}
|
||||
|
||||
// Delete local files that don't exist remotely (when remote is newer)
|
||||
for path in &diff.files_to_delete_local {
|
||||
let file_path = profile_dir.join(path);
|
||||
@@ -823,6 +875,7 @@ impl SyncEngine {
|
||||
files: &[super::manifest::ManifestFileEntry],
|
||||
encryption_key: Option<&[u8; 32]>,
|
||||
key_prefix: &str,
|
||||
cancel_flag: &Arc<AtomicBool>,
|
||||
) -> SyncResult<()> {
|
||||
if files.is_empty() {
|
||||
return Ok(());
|
||||
@@ -930,6 +983,13 @@ impl SyncEngine {
|
||||
let save_counter = Arc::new(AtomicU64::new(0));
|
||||
|
||||
for file in &files_to_process {
|
||||
if cancel_flag.load(Ordering::Relaxed) {
|
||||
log::info!(
|
||||
"Upload cancelled for profile {} before scheduling more files",
|
||||
profile_id_owned
|
||||
);
|
||||
break;
|
||||
}
|
||||
let sem = semaphore.clone();
|
||||
let file_path = profile_dir.join(&file.path);
|
||||
let relative_path = file.path.clone();
|
||||
@@ -958,6 +1018,7 @@ impl SyncEngine {
|
||||
let resume_state = resume_state.clone();
|
||||
let save_counter = save_counter.clone();
|
||||
let profile_dir_clone = profile_dir.clone();
|
||||
let cancel_flag_task = cancel_flag.clone();
|
||||
let content_type = mime_guess::from_path(&file.path)
|
||||
.first()
|
||||
.map(|m| m.to_string());
|
||||
@@ -965,6 +1026,10 @@ impl SyncEngine {
|
||||
handles.push(tokio::spawn(async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
|
||||
if cancel_flag_task.load(Ordering::Relaxed) {
|
||||
return Err((relative_path, "cancelled".to_string(), false));
|
||||
}
|
||||
|
||||
let data = match fs::read(&file_path) {
|
||||
Ok(d) => d,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !critical => {
|
||||
@@ -1095,6 +1160,7 @@ impl SyncEngine {
|
||||
files: &[super::manifest::ManifestFileEntry],
|
||||
encryption_key: Option<&[u8; 32]>,
|
||||
key_prefix: &str,
|
||||
cancel_flag: &Arc<AtomicBool>,
|
||||
) -> SyncResult<()> {
|
||||
if files.is_empty() {
|
||||
return Ok(());
|
||||
@@ -1194,6 +1260,13 @@ impl SyncEngine {
|
||||
let save_counter = Arc::new(AtomicU64::new(0));
|
||||
|
||||
for file in &files_to_process {
|
||||
if cancel_flag.load(Ordering::Relaxed) {
|
||||
log::info!(
|
||||
"Download cancelled for profile {} before scheduling more files",
|
||||
profile_id_owned
|
||||
);
|
||||
break;
|
||||
}
|
||||
let sem = semaphore.clone();
|
||||
let file_path = profile_dir.join(&file.path);
|
||||
let relative_path = file.path.clone();
|
||||
@@ -1222,13 +1295,21 @@ impl SyncEngine {
|
||||
let resume_state = resume_state.clone();
|
||||
let save_counter = save_counter.clone();
|
||||
let profile_dir_clone = profile_dir.clone();
|
||||
let cancel_flag_task = cancel_flag.clone();
|
||||
|
||||
handles.push(tokio::spawn(async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
|
||||
if cancel_flag_task.load(Ordering::Relaxed) {
|
||||
return Err((relative_path, "cancelled".to_string(), false));
|
||||
}
|
||||
|
||||
// Retry loop for network downloads
|
||||
let mut last_err = String::new();
|
||||
for attempt in 0..MAX_FILE_RETRIES {
|
||||
if cancel_flag_task.load(Ordering::Relaxed) {
|
||||
return Err((relative_path, "cancelled".to_string(), false));
|
||||
}
|
||||
match client.download_bytes(&url).await {
|
||||
Ok(data) => {
|
||||
let write_data = if let Some(ref key) = enc_key {
|
||||
@@ -2361,6 +2442,8 @@ impl SyncEngine {
|
||||
);
|
||||
}
|
||||
if !manifest.files.is_empty() {
|
||||
let cancel_flag = register_sync_cancel(profile_id);
|
||||
let _cancel_guard = SyncCancelGuard(profile_id.to_string());
|
||||
self
|
||||
.download_profile_files(
|
||||
app_handle,
|
||||
@@ -2370,6 +2453,7 @@ impl SyncEngine {
|
||||
&manifest.files,
|
||||
encryption_key.as_ref(),
|
||||
key_prefix,
|
||||
&cancel_flag,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -2506,8 +2590,46 @@ impl SyncEngine {
|
||||
profiles_to_check.len()
|
||||
);
|
||||
|
||||
// For each remote profile, check if it exists locally and download if missing
|
||||
// For each remote profile, check if it exists locally and download if missing.
|
||||
// Skip any profile that has a tombstone — a leftover manifest under a
|
||||
// tombstoned id means delete_prefix raced or partially failed, and
|
||||
// re-downloading it here is what surfaced the "Browsing keeps re-syncing"
|
||||
// bug after a delete.
|
||||
for (profile_id, key_prefix) in &profiles_to_check {
|
||||
let personal_tombstone = format!("tombstones/profiles/{}.json", profile_id);
|
||||
let has_personal_tombstone = matches!(
|
||||
self.client.stat(&personal_tombstone).await,
|
||||
Ok(stat) if stat.exists
|
||||
);
|
||||
let team_tombstone_key = if key_prefix.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
"{}tombstones/profiles/{}.json",
|
||||
key_prefix, profile_id
|
||||
))
|
||||
};
|
||||
let has_team_tombstone = if let Some(ref tk) = team_tombstone_key {
|
||||
matches!(self.client.stat(tk).await, Ok(stat) if stat.exists)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if has_personal_tombstone || has_team_tombstone {
|
||||
log::info!(
|
||||
"Skipping download of tombstoned profile {} (clearing leftover remote files)",
|
||||
profile_id
|
||||
);
|
||||
let prefix = format!("{}profiles/{}/", key_prefix, profile_id);
|
||||
if let Err(e) = self.client.delete_prefix(&prefix, None).await {
|
||||
log::warn!(
|
||||
"Failed to clear stale remote files for tombstoned profile {}: {}",
|
||||
profile_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match self
|
||||
.download_profile_if_missing(app_handle, profile_id, key_prefix)
|
||||
.await
|
||||
@@ -2571,6 +2693,24 @@ impl SyncEngine {
|
||||
};
|
||||
|
||||
if has_personal_tombstone || has_team_tombstone {
|
||||
// Originator guard: re-read the profile right before deleting. If the
|
||||
// local user disabled sync between the snapshot above and this stat
|
||||
// call, they're the one who wrote this tombstone — keep their local
|
||||
// copy. Tombstones must delete remote-originated changes, never the
|
||||
// sender's own data. (Caused mass local deletion in v0.24.x.)
|
||||
let still_sync_enabled = profile_manager
|
||||
.list_profiles()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.find(|p| p.id.to_string() == *pid)
|
||||
.is_some_and(|p| p.is_sync_enabled());
|
||||
if !still_sync_enabled {
|
||||
log::info!(
|
||||
"Profile {} has a tombstone but sync is no longer enabled locally — keeping local copy (originating device)",
|
||||
pid
|
||||
);
|
||||
continue;
|
||||
}
|
||||
log::info!(
|
||||
"Profile {} has remote tombstone, deleting locally (deleted on another device)",
|
||||
pid
|
||||
@@ -2948,6 +3088,11 @@ pub async fn set_profile_sync_mode(
|
||||
return Err("Cannot modify sync settings for a cross-OS profile".to_string());
|
||||
}
|
||||
|
||||
let enabling_now = new_mode != SyncMode::Disabled;
|
||||
if enabling_now && profile.process_id.is_some() {
|
||||
return Err(serde_json::json!({ "code": "PROFILE_RUNNING" }).to_string());
|
||||
}
|
||||
|
||||
if profile.ephemeral {
|
||||
return Err("Cannot enable sync for an ephemeral profile".to_string());
|
||||
}
|
||||
@@ -3029,6 +3174,22 @@ pub async fn set_profile_sync_mode(
|
||||
|
||||
let _ = events::emit("profiles-changed", ());
|
||||
|
||||
// When (re-)enabling sync, clear any stale tombstone from a previous
|
||||
// disable on this device. Otherwise the next reconcile on another
|
||||
// device — or even a race on this one — would see the tombstone and
|
||||
// delete the freshly re-uploaded data.
|
||||
if enabling {
|
||||
if let Ok(engine) = SyncEngine::create_from_settings(&app_handle).await {
|
||||
let key_prefix = SyncEngine::get_team_key_prefix(&profile).await;
|
||||
let personal_tombstone = format!("tombstones/profiles/{}.json", profile_id);
|
||||
let _ = engine.client.delete(&personal_tombstone, None).await;
|
||||
if !key_prefix.is_empty() {
|
||||
let team_tombstone = format!("{}tombstones/profiles/{}.json", key_prefix, profile_id);
|
||||
let _ = engine.client.delete(&team_tombstone, None).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if enabling {
|
||||
let is_running = profile.process_id.is_some();
|
||||
|
||||
@@ -3084,28 +3245,25 @@ pub async fn set_profile_sync_mode(
|
||||
log::warn!("Scheduler not initialized, sync will not start");
|
||||
}
|
||||
} else {
|
||||
// Delete remote data when disabling sync
|
||||
// Delete remote data when disabling sync. Awaited (not spawned) so the
|
||||
// tombstone write completes before this command returns. A previous
|
||||
// tokio::spawn here allowed the tombstone-write to land *after* a fast
|
||||
// user-triggered re-enable's tombstone-clear, re-introducing the
|
||||
// tombstone and tripping the reconcile-pass deletion of a profile the
|
||||
// user had just re-enabled (e.g. Personal (z.ai) on 2026-05-20).
|
||||
if old_mode != SyncMode::Disabled {
|
||||
let profile_id_clone = profile_id.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
tokio::spawn(async move {
|
||||
match SyncEngine::create_from_settings(&app_handle_clone).await {
|
||||
Ok(engine) => {
|
||||
if let Err(e) = engine.delete_profile(&profile_id_clone).await {
|
||||
log::warn!(
|
||||
"Failed to delete profile {} from sync: {}",
|
||||
profile_id_clone,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
log::info!("Profile {} deleted from sync service", profile_id_clone);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Sync not configured, skipping remote deletion: {}", e);
|
||||
match SyncEngine::create_from_settings(&app_handle).await {
|
||||
Ok(engine) => {
|
||||
if let Err(e) = engine.delete_profile(&profile_id).await {
|
||||
log::warn!("Failed to delete profile {} from sync: {}", profile_id, e);
|
||||
} else {
|
||||
log::info!("Profile {} deleted from sync service", profile_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
Err(e) => {
|
||||
log::debug!("Sync not configured, skipping remote deletion: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = events::emit(
|
||||
@@ -3183,6 +3341,28 @@ pub async fn sync_profile(app_handle: tauri::AppHandle, profile_id: String) -> R
|
||||
trigger_sync_for_profile(app_handle, profile_id).await
|
||||
}
|
||||
|
||||
/// Ensure the device has either a cloud login or a self-hosted server URL + token.
|
||||
/// Returns a JSON error code string consumable by the frontend translator.
|
||||
async fn ensure_sync_configured(app_handle: &tauri::AppHandle) -> Result<(), String> {
|
||||
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
|
||||
if cloud_logged_in {
|
||||
return Ok(());
|
||||
}
|
||||
let manager = SettingsManager::instance();
|
||||
let settings = manager.load_settings().map_err(|e| {
|
||||
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
|
||||
.to_string()
|
||||
})?;
|
||||
if settings.sync_server_url.is_none() {
|
||||
return Err(serde_json::json!({ "code": "SYNC_NOT_CONFIGURED" }).to_string());
|
||||
}
|
||||
let token = manager.get_sync_token(app_handle).await.ok().flatten();
|
||||
if token.is_none() {
|
||||
return Err(serde_json::json!({ "code": "SYNC_NOT_CONFIGURED" }).to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn trigger_sync_for_profile(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
@@ -3222,43 +3402,29 @@ pub async fn set_proxy_sync_enabled(
|
||||
let proxy = proxies
|
||||
.iter()
|
||||
.find(|p| p.id == proxy_id)
|
||||
.ok_or_else(|| format!("Proxy with ID '{proxy_id}' not found"))?;
|
||||
.ok_or_else(|| serde_json::json!({ "code": "PROXY_NOT_FOUND" }).to_string())?;
|
||||
|
||||
// Block modifying sync for cloud-managed proxies
|
||||
if proxy.is_cloud_managed {
|
||||
return Err("Cannot modify sync for a cloud-managed proxy".to_string());
|
||||
return Err(serde_json::json!({ "code": "CANNOT_MODIFY_CLOUD_MANAGED_PROXY" }).to_string());
|
||||
}
|
||||
|
||||
// If disabling, check if proxy is used by any synced profile
|
||||
if !enabled && is_proxy_used_by_synced_profile(&proxy_id) {
|
||||
return Err("Sync cannot be disabled while this proxy is used by synced profiles".to_string());
|
||||
return Err(serde_json::json!({ "code": "SYNC_LOCKED_BY_PROFILE" }).to_string());
|
||||
}
|
||||
|
||||
// If enabling, check that sync settings are configured
|
||||
if enabled {
|
||||
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
|
||||
|
||||
if !cloud_logged_in {
|
||||
let manager = SettingsManager::instance();
|
||||
let settings = manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
|
||||
if settings.sync_server_url.is_none() {
|
||||
return Err(
|
||||
"Sync server not configured. Please configure sync settings first.".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
|
||||
if token.is_none() {
|
||||
return Err("Sync token not configured. Please configure sync settings first.".to_string());
|
||||
}
|
||||
}
|
||||
ensure_sync_configured(&app_handle).await?;
|
||||
}
|
||||
|
||||
let new_last_sync = if enabled { proxy.last_sync } else { None };
|
||||
proxy_manager.set_stored_proxy_sync_state(&proxy_id, enabled, new_last_sync)?;
|
||||
proxy_manager
|
||||
.set_stored_proxy_sync_state(&proxy_id, enabled, new_last_sync)
|
||||
.map_err(|e| {
|
||||
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e } }).to_string()
|
||||
})?;
|
||||
|
||||
let _ = events::emit("stored-proxies-changed", ());
|
||||
|
||||
@@ -3299,36 +3465,18 @@ pub async fn set_group_sync_enabled(
|
||||
groups
|
||||
.iter()
|
||||
.find(|g| g.id == group_id)
|
||||
.ok_or_else(|| format!("Group with ID '{group_id}' not found"))?
|
||||
.ok_or_else(|| serde_json::json!({ "code": "GROUP_NOT_FOUND" }).to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
// If disabling, check if group is used by any synced profile
|
||||
if !enabled && is_group_used_by_synced_profile(&group_id) {
|
||||
return Err("Sync cannot be disabled while this group is used by synced profiles".to_string());
|
||||
return Err(serde_json::json!({ "code": "SYNC_LOCKED_BY_PROFILE" }).to_string());
|
||||
}
|
||||
|
||||
// If enabling, check that sync settings are configured
|
||||
if enabled {
|
||||
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
|
||||
|
||||
if !cloud_logged_in {
|
||||
let manager = SettingsManager::instance();
|
||||
let settings = manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
|
||||
if settings.sync_server_url.is_none() {
|
||||
return Err(
|
||||
"Sync server not configured. Please configure sync settings first.".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
|
||||
if token.is_none() {
|
||||
return Err("Sync token not configured. Please configure sync settings first.".to_string());
|
||||
}
|
||||
}
|
||||
ensure_sync_configured(&app_handle).await?;
|
||||
}
|
||||
|
||||
let mut updated_group = group.clone();
|
||||
@@ -3341,7 +3489,10 @@ pub async fn set_group_sync_enabled(
|
||||
{
|
||||
let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap();
|
||||
if let Err(e) = group_manager.update_group_internal(&updated_group) {
|
||||
return Err(format!("Failed to update group: {e}"));
|
||||
return Err(
|
||||
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3392,35 +3543,17 @@ pub async fn set_vpn_sync_enabled(
|
||||
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
|
||||
storage
|
||||
.load_config(&vpn_id)
|
||||
.map_err(|e| format!("VPN with ID '{vpn_id}' not found: {e}"))?
|
||||
.map_err(|_| serde_json::json!({ "code": "VPN_NOT_FOUND" }).to_string())?
|
||||
};
|
||||
|
||||
// If disabling, check if VPN is used by any synced profile
|
||||
if !enabled && is_vpn_used_by_synced_profile(&vpn_id) {
|
||||
return Err("Sync cannot be disabled while this VPN is used by synced profiles".to_string());
|
||||
return Err(serde_json::json!({ "code": "SYNC_LOCKED_BY_PROFILE" }).to_string());
|
||||
}
|
||||
|
||||
// If enabling, check that sync settings are configured
|
||||
if enabled {
|
||||
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
|
||||
|
||||
if !cloud_logged_in {
|
||||
let manager = SettingsManager::instance();
|
||||
let settings = manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
|
||||
if settings.sync_server_url.is_none() {
|
||||
return Err(
|
||||
"Sync server not configured. Please configure sync settings first.".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
|
||||
if token.is_none() {
|
||||
return Err("Sync token not configured. Please configure sync settings first.".to_string());
|
||||
}
|
||||
}
|
||||
ensure_sync_configured(&app_handle).await?;
|
||||
}
|
||||
|
||||
let last_sync = if enabled { vpn.last_sync } else { None };
|
||||
@@ -3429,7 +3562,10 @@ pub async fn set_vpn_sync_enabled(
|
||||
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
|
||||
storage
|
||||
.update_sync_fields(&vpn_id, enabled, last_sync)
|
||||
.map_err(|e| format!("Failed to update VPN sync: {e}"))?;
|
||||
.map_err(|e| {
|
||||
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
|
||||
.to_string()
|
||||
})?;
|
||||
}
|
||||
|
||||
let _ = events::emit("vpn-configs-changed", ());
|
||||
@@ -3526,48 +3662,10 @@ pub fn get_unsynced_entity_counts() -> Result<UnsyncedEntityCounts, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
// Enable sync for all eligible profiles. Without this the user would see
|
||||
// groups/proxies/vpns syncing while their profiles stay local-only — the
|
||||
// long-standing source of issue #352. Encrypted mode wins when an E2E
|
||||
// password is already configured; otherwise we fall back to plain Regular.
|
||||
{
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
let desired_mode = if encryption::has_e2e_password() {
|
||||
SyncMode::Encrypted
|
||||
} else {
|
||||
SyncMode::Regular
|
||||
};
|
||||
let desired_mode_str = match desired_mode {
|
||||
SyncMode::Encrypted => "Encrypted",
|
||||
SyncMode::Regular => "Regular",
|
||||
SyncMode::Disabled => "Disabled",
|
||||
};
|
||||
for profile in &profiles {
|
||||
// Skip profiles that are already syncing (any non-Disabled mode),
|
||||
// ephemeral profiles (data wipes on quit, sync is meaningless), and
|
||||
// cross-OS profiles (the OS-specific binary isn't installed locally
|
||||
// so a sync round-trip would be one-sided).
|
||||
if profile.sync_mode != SyncMode::Disabled || profile.ephemeral || profile.is_cross_os() {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = set_profile_sync_mode(
|
||||
app_handle.clone(),
|
||||
profile.id.to_string(),
|
||||
desired_mode_str.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to enable sync for profile {} ({}): {e}",
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Intentionally excludes profiles: enabling profile sync uploads the entire
|
||||
// browser data dir per profile, which is destructive if the user expected
|
||||
// an opt-in. Profile sync stays under explicit per-profile control via
|
||||
// set_profile_sync_mode. This command only touches metadata-sized entities.
|
||||
|
||||
// Enable sync for all unsynced proxies
|
||||
{
|
||||
@@ -3664,26 +3762,11 @@ pub async fn set_extension_sync_enabled(
|
||||
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
manager
|
||||
.get_extension(&extension_id)
|
||||
.map_err(|e| format!("Extension with ID '{extension_id}' not found: {e}"))?
|
||||
.map_err(|_| serde_json::json!({ "code": "EXTENSION_NOT_FOUND" }).to_string())?
|
||||
};
|
||||
|
||||
if enabled {
|
||||
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
|
||||
if !cloud_logged_in {
|
||||
let manager = SettingsManager::instance();
|
||||
let settings = manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
if settings.sync_server_url.is_none() {
|
||||
return Err(
|
||||
"Sync server not configured. Please configure sync settings first.".to_string(),
|
||||
);
|
||||
}
|
||||
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
|
||||
if token.is_none() {
|
||||
return Err("Sync token not configured. Please configure sync settings first.".to_string());
|
||||
}
|
||||
}
|
||||
ensure_sync_configured(&app_handle).await?;
|
||||
}
|
||||
|
||||
let mut updated_ext = ext;
|
||||
@@ -3696,7 +3779,10 @@ pub async fn set_extension_sync_enabled(
|
||||
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
manager
|
||||
.update_extension_internal(&updated_ext)
|
||||
.map_err(|e| format!("Failed to update extension sync: {e}"))?;
|
||||
.map_err(|e| {
|
||||
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
|
||||
.to_string()
|
||||
})?;
|
||||
}
|
||||
|
||||
let _ = events::emit("extensions-changed", ());
|
||||
@@ -3720,26 +3806,11 @@ pub async fn set_extension_group_sync_enabled(
|
||||
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
manager
|
||||
.get_group(&extension_group_id)
|
||||
.map_err(|e| format!("Extension group with ID '{extension_group_id}' not found: {e}"))?
|
||||
.map_err(|_| serde_json::json!({ "code": "EXTENSION_GROUP_NOT_FOUND" }).to_string())?
|
||||
};
|
||||
|
||||
if enabled {
|
||||
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
|
||||
if !cloud_logged_in {
|
||||
let manager = SettingsManager::instance();
|
||||
let settings = manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
if settings.sync_server_url.is_none() {
|
||||
return Err(
|
||||
"Sync server not configured. Please configure sync settings first.".to_string(),
|
||||
);
|
||||
}
|
||||
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
|
||||
if token.is_none() {
|
||||
return Err("Sync token not configured. Please configure sync settings first.".to_string());
|
||||
}
|
||||
}
|
||||
ensure_sync_configured(&app_handle).await?;
|
||||
}
|
||||
|
||||
let mut updated_group = group;
|
||||
@@ -3750,9 +3821,10 @@ pub async fn set_extension_group_sync_enabled(
|
||||
|
||||
{
|
||||
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
manager
|
||||
.update_group_internal(&updated_group)
|
||||
.map_err(|e| format!("Failed to update extension group sync: {e}"))?;
|
||||
manager.update_group_internal(&updated_group).map_err(|e| {
|
||||
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
|
||||
.to_string()
|
||||
})?;
|
||||
}
|
||||
|
||||
let _ = events::emit("extensions-changed", ());
|
||||
|
||||
@@ -35,6 +35,16 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
|
||||
"**/startupCache/**",
|
||||
"**/safebrowsing/**",
|
||||
"**/storage/temporary/**",
|
||||
"**/storage/default/*/cache/**",
|
||||
"**/datareporting/**",
|
||||
"**/saved-telemetry-pings/**",
|
||||
"**/sessionstore-backups/**",
|
||||
"**/sessions/**",
|
||||
"**/serviceworker.txt",
|
||||
"**/AlternateServices.bin",
|
||||
"**/SiteSecurityServiceState.bin",
|
||||
"**/favicons.sqlite",
|
||||
"**/favicons.sqlite-*",
|
||||
"**/crashes/**",
|
||||
"**/minidumps/**",
|
||||
"*.tmp",
|
||||
|
||||
@@ -11,9 +11,9 @@ pub use encryption::{
|
||||
check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password,
|
||||
};
|
||||
pub use engine::{
|
||||
enable_extension_group_sync_if_needed, enable_group_sync_if_needed, enable_proxy_sync_if_needed,
|
||||
enable_sync_for_all_entities, enable_vpn_sync_if_needed, get_unsynced_entity_counts,
|
||||
is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
|
||||
cancel_profile_sync, enable_extension_group_sync_if_needed, enable_group_sync_if_needed,
|
||||
enable_proxy_sync_if_needed, enable_sync_for_all_entities, enable_vpn_sync_if_needed,
|
||||
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
|
||||
is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured,
|
||||
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, request_profile_sync,
|
||||
rollover_encryption_for_all_entities, set_extension_group_sync_enabled,
|
||||
|
||||
@@ -716,16 +716,18 @@ impl SyncScheduler {
|
||||
match entity_type.as_str() {
|
||||
"profile" => {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let has_profile = {
|
||||
let local_sync_enabled = {
|
||||
if let Ok(profiles) = profile_manager.list_profiles() {
|
||||
let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok();
|
||||
profile_uuid.is_some_and(|uuid| profiles.iter().any(|p| p.id == uuid))
|
||||
profile_uuid
|
||||
.and_then(|uuid| profiles.into_iter().find(|p| p.id == uuid))
|
||||
.is_some_and(|p| p.is_sync_enabled())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if has_profile {
|
||||
if local_sync_enabled {
|
||||
log::info!(
|
||||
"Profile {} was deleted remotely, deleting locally",
|
||||
entity_id
|
||||
@@ -733,6 +735,11 @@ impl SyncScheduler {
|
||||
if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) {
|
||||
log::warn!("Failed to delete tombstoned profile {}: {}", entity_id, e);
|
||||
}
|
||||
} else {
|
||||
log::info!(
|
||||
"Profile {} has a tombstone but sync is no longer enabled locally — keeping local copy",
|
||||
entity_id
|
||||
);
|
||||
}
|
||||
}
|
||||
"proxy" => {
|
||||
|
||||
@@ -166,6 +166,7 @@ pub enum SyncError {
|
||||
SerializationError(String),
|
||||
ConflictError(String),
|
||||
InvalidData(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SyncError {
|
||||
@@ -178,6 +179,7 @@ impl std::fmt::Display for SyncError {
|
||||
SyncError::SerializationError(msg) => write!(f, "Serialization error: {msg}"),
|
||||
SyncError::ConflictError(msg) => write!(f, "Conflict error: {msg}"),
|
||||
SyncError::InvalidData(msg) => write!(f, "Invalid data: {msg}"),
|
||||
SyncError::Cancelled => write!(f, "Sync cancelled by user"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.24.2",
|
||||
"version": "0.24.3",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
+175
-1
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { AccountPage } from "@/components/account-page";
|
||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
||||
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
||||
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
|
||||
@@ -34,6 +35,7 @@ import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
|
||||
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
|
||||
import { type AppPage, RailNav } from "@/components/rail-nav";
|
||||
import { SettingsDialog } from "@/components/settings-dialog";
|
||||
import { ShortcutsPage } from "@/components/shortcuts-page";
|
||||
import { SyncAllDialog } from "@/components/sync-all-dialog";
|
||||
import { SyncConfigDialog } from "@/components/sync-config-dialog";
|
||||
import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
|
||||
@@ -53,6 +55,12 @@ 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 {
|
||||
matchesGroupDigit,
|
||||
matchesShortcut,
|
||||
SHORTCUTS,
|
||||
type ShortcutId,
|
||||
} from "@/lib/shortcuts";
|
||||
import {
|
||||
dismissToast,
|
||||
showErrorToast,
|
||||
@@ -149,6 +157,11 @@ export default function Home() {
|
||||
const [proxyManagementInitialTab, setProxyManagementInitialTab] = useState<
|
||||
"proxies" | "vpns"
|
||||
>("proxies");
|
||||
const [extensionManagementInitialTab, setExtensionManagementInitialTab] =
|
||||
useState<"extensions" | "groups">("extensions");
|
||||
const [integrationsInitialTab, setIntegrationsInitialTab] = useState<
|
||||
"api" | "mcp"
|
||||
>("api");
|
||||
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
|
||||
@@ -221,6 +234,11 @@ export default function Home() {
|
||||
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
|
||||
const [currentProfileForSync, setCurrentProfileForSync] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
// Owned by page.tsx so the command palette can request opening the profile
|
||||
// info dialog. ProfilesDataTable consumes it through controlled props.
|
||||
const [profileInfoDialog, setProfileInfoDialog] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
||||
usePermissions();
|
||||
|
||||
@@ -273,9 +291,134 @@ export default function Home() {
|
||||
case "account":
|
||||
setAccountDialogOpen(true);
|
||||
break;
|
||||
case "shortcuts":
|
||||
// Plain page render — nothing else to open.
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runShortcut = useCallback(
|
||||
(id: ShortcutId) => {
|
||||
switch (id) {
|
||||
case "openPalette":
|
||||
setCommandPaletteOpen(true);
|
||||
break;
|
||||
case "openShortcuts":
|
||||
handleRailNavigate("shortcuts");
|
||||
break;
|
||||
case "importProfile":
|
||||
handleRailNavigate("import");
|
||||
break;
|
||||
case "goProfiles":
|
||||
handleRailNavigate("profiles");
|
||||
break;
|
||||
case "goProxies": {
|
||||
// Mod+N: navigate first time; flip proxies↔vpns on subsequent presses.
|
||||
// handleRailNavigate("proxies"|"vpns") already updates the dialog's
|
||||
// initialTab, so we just pick the right destination.
|
||||
if (currentPage === "proxies") {
|
||||
handleRailNavigate("vpns");
|
||||
} else if (currentPage === "vpns") {
|
||||
handleRailNavigate("proxies");
|
||||
} else {
|
||||
handleRailNavigate(
|
||||
proxyManagementInitialTab === "vpns" ? "vpns" : "proxies",
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "goExtensions": {
|
||||
// Mod+E: flip extensions↔groups tab inside the dialog when already there.
|
||||
if (currentPage === "extensions") {
|
||||
setExtensionManagementInitialTab((cur) =>
|
||||
cur === "extensions" ? "groups" : "extensions",
|
||||
);
|
||||
} else {
|
||||
handleRailNavigate("extensions");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "goGroups":
|
||||
handleRailNavigate("groups");
|
||||
break;
|
||||
case "goIntegrations": {
|
||||
// Mod+I: flip api↔mcp tab when already on integrations.
|
||||
if (currentPage === "integrations") {
|
||||
setIntegrationsInitialTab((cur) => (cur === "api" ? "mcp" : "api"));
|
||||
} else {
|
||||
handleRailNavigate("integrations");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "goAccount":
|
||||
handleRailNavigate("account");
|
||||
break;
|
||||
case "goSettings":
|
||||
handleRailNavigate("settings");
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleRailNavigate, currentPage, proxyManagementInitialTab],
|
||||
);
|
||||
|
||||
// Ordered list the digit shortcuts and palette consume. "__all__" is index 1
|
||||
// so Mod+1 always lands on the unfiltered view; the user's groups follow.
|
||||
const orderedGroupTargets = useMemo(
|
||||
() => [
|
||||
{ id: "__all__", name: t("rail.profiles") },
|
||||
...groupsData.map((g) => ({ id: g.id, name: g.name })),
|
||||
],
|
||||
[groupsData, t],
|
||||
);
|
||||
|
||||
const selectGroupByDigit = useCallback(
|
||||
(digit: number) => {
|
||||
const target = orderedGroupTargets[digit - 1];
|
||||
if (!target) return;
|
||||
handleRailNavigate("profiles");
|
||||
handleSelectGroup(target.id);
|
||||
},
|
||||
[orderedGroupTargets, handleRailNavigate, handleSelectGroup],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Global keydown — handles Mod+1..9 group jumps first, then falls back to
|
||||
// the static SHORTCUTS table. Skipped while typing in an input, EXCEPT
|
||||
// ⌘K and ⌘/ which are meta-level shortcuts and should always be reachable.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const tag = target?.tagName;
|
||||
const isTyping =
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
tag === "SELECT" ||
|
||||
target?.isContentEditable === true;
|
||||
|
||||
const digit = matchesGroupDigit(e);
|
||||
if (digit !== null) {
|
||||
if (isTyping) return;
|
||||
if (digit - 1 >= orderedGroupTargets.length) return;
|
||||
e.preventDefault();
|
||||
selectGroupByDigit(digit);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const s of SHORTCUTS) {
|
||||
if (!matchesShortcut(s, e)) continue;
|
||||
if (isTyping && s.id !== "openPalette" && s.id !== "openShortcuts") {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
runShortcut(s.id);
|
||||
return;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [runShortcut, selectGroupByDigit, orderedGroupTargets.length]);
|
||||
|
||||
// Check for missing binaries and offer to download them
|
||||
const checkMissingBinaries = useCallback(async () => {
|
||||
try {
|
||||
@@ -1031,7 +1174,7 @@ export default function Home() {
|
||||
failed_count: payload.failed_count ?? 0,
|
||||
phase: payload.phase,
|
||||
},
|
||||
{ id: toastId },
|
||||
{ id: toastId, profileId: payload.profile_id },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1306,6 +1449,8 @@ export default function Home() {
|
||||
{isLoading && groupsData.length === 0 ? null : null}
|
||||
<ProfilesDataTable
|
||||
profiles={filteredProfiles}
|
||||
infoDialogProfile={profileInfoDialog}
|
||||
onInfoDialogProfileChange={setProfileInfoDialog}
|
||||
onLaunchProfile={launchProfile}
|
||||
onKillProfile={handleKillProfile}
|
||||
onCloneProfile={handleCloneProfile}
|
||||
@@ -1344,6 +1489,10 @@ export default function Home() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPage === "shortcuts" && (
|
||||
<ShortcutsPage groupTargets={orderedGroupTargets} />
|
||||
)}
|
||||
|
||||
{settingsDialogOpen && (
|
||||
<SettingsDialog
|
||||
isOpen={settingsDialogOpen}
|
||||
@@ -1368,6 +1517,7 @@ export default function Home() {
|
||||
setCurrentPage("profiles");
|
||||
}}
|
||||
subPage={currentPage === "integrations"}
|
||||
initialTab={integrationsInitialTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1404,6 +1554,7 @@ export default function Home() {
|
||||
}}
|
||||
limitedMode={false}
|
||||
subPage={currentPage === "extensions"}
|
||||
initialTab={extensionManagementInitialTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1447,6 +1598,29 @@ export default function Home() {
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
|
||||
<CommandPalette
|
||||
open={commandPaletteOpen}
|
||||
onOpenChange={setCommandPaletteOpen}
|
||||
onAction={runShortcut}
|
||||
groupTargets={orderedGroupTargets}
|
||||
onSelectGroup={(id) => {
|
||||
handleRailNavigate("profiles");
|
||||
handleSelectGroup(id);
|
||||
}}
|
||||
profiles={profiles}
|
||||
runningProfileIds={runningProfiles}
|
||||
onLaunchProfile={(profile) => {
|
||||
void launchProfile(profile);
|
||||
}}
|
||||
onKillProfile={(profile) => {
|
||||
void handleKillProfile(profile);
|
||||
}}
|
||||
onShowProfileInfo={(profile) => {
|
||||
handleRailNavigate("profiles");
|
||||
setProfileInfoDialog(profile);
|
||||
}}
|
||||
/>
|
||||
|
||||
{pendingUrls.map((pendingUrl) => (
|
||||
<ProfileSelectorDialog
|
||||
key={pendingUrl.id}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear } from "react-icons/go";
|
||||
import {
|
||||
LuCircleStop,
|
||||
LuCloud,
|
||||
LuInfo,
|
||||
LuKeyboard,
|
||||
LuPlay,
|
||||
LuPlug,
|
||||
LuPuzzle,
|
||||
LuUser,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
formatGroupShortcut,
|
||||
formatShortcut,
|
||||
SHORTCUTS,
|
||||
type ShortcutDef,
|
||||
type ShortcutId,
|
||||
} from "@/lib/shortcuts";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
|
||||
interface GroupTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAction: (id: ShortcutId) => void;
|
||||
/** Ordered list of groups for Mod+1..9. Index 0 is the catch-all entry. */
|
||||
groupTargets: GroupTarget[];
|
||||
onSelectGroup: (id: string) => void;
|
||||
/** All profiles for launch/stop/info entries. */
|
||||
profiles: BrowserProfile[];
|
||||
runningProfileIds: Set<string>;
|
||||
onLaunchProfile: (profile: BrowserProfile) => void;
|
||||
onKillProfile: (profile: BrowserProfile) => void;
|
||||
onShowProfileInfo: (profile: BrowserProfile) => void;
|
||||
}
|
||||
|
||||
const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
|
||||
openPalette: LuKeyboard,
|
||||
openShortcuts: LuKeyboard,
|
||||
importProfile: FaDownload,
|
||||
goProfiles: LuUser,
|
||||
goProxies: FiWifi,
|
||||
goExtensions: LuPuzzle,
|
||||
goGroups: LuUsers,
|
||||
goIntegrations: LuPlug,
|
||||
goAccount: LuCloud,
|
||||
goSettings: GoGear,
|
||||
};
|
||||
|
||||
function Tokens({ tokens }: { tokens: string[] }) {
|
||||
return (
|
||||
<CommandShortcut className="flex items-center gap-0.5">
|
||||
{tokens.map((tok, i) => (
|
||||
<kbd
|
||||
key={i}
|
||||
className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded border border-border bg-muted text-[10px] font-medium text-muted-foreground"
|
||||
>
|
||||
{tok}
|
||||
</kbd>
|
||||
))}
|
||||
</CommandShortcut>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutTokens({ shortcut }: { shortcut: ShortcutDef }) {
|
||||
return <Tokens tokens={formatShortcut(shortcut)} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token-AND fuzzy filter. Every whitespace-separated token in the query has
|
||||
* to appear as a substring somewhere in the item's value or its keywords; the
|
||||
* score is reduced when tokens appear later in the haystack so a closer match
|
||||
* sorts higher. "ctest info" matches "Info — ctest" — the default cmdk filter
|
||||
* requires tokens in document order so it would otherwise return zero.
|
||||
*/
|
||||
function fuzzyFilter(
|
||||
value: string,
|
||||
search: string,
|
||||
keywords?: string[],
|
||||
): number {
|
||||
if (!search.trim()) return 1;
|
||||
const haystack = [value, ...(keywords ?? [])].join(" ").toLowerCase();
|
||||
const tokens = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
let score = 0;
|
||||
for (const tok of tokens) {
|
||||
const idx = haystack.indexOf(tok);
|
||||
if (idx === -1) return 0;
|
||||
score += 1 / (1 + idx);
|
||||
}
|
||||
return score / tokens.length;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAction,
|
||||
groupTargets,
|
||||
onSelectGroup,
|
||||
profiles,
|
||||
runningProfileIds,
|
||||
onLaunchProfile,
|
||||
onKillProfile,
|
||||
onShowProfileInfo,
|
||||
}: CommandPaletteProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// `cmdk` calls onSelect BEFORE the dialog closes. Close first, then dispatch
|
||||
// on the next tick so an action that opens another dialog doesn't race
|
||||
// this one's close animation.
|
||||
const dispatch = (fn: () => void) => {
|
||||
onOpenChange(false);
|
||||
setTimeout(fn, 0);
|
||||
};
|
||||
|
||||
const byGroup = (group: ShortcutDef["group"]) =>
|
||||
SHORTCUTS.filter((s) => s.group === group);
|
||||
|
||||
// Limit to 9 — only the first 9 group targets have a Mod+digit binding.
|
||||
// We still display more in the palette (without a shortcut hint) so the
|
||||
// user can search/jump to any of them.
|
||||
const renderGroup = (target: GroupTarget, index: number) => (
|
||||
<CommandItem
|
||||
key={target.id}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onSelectGroup(target.id);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LuUsers />
|
||||
<span>{target.name}</span>
|
||||
{index < 9 ? <Tokens tokens={formatGroupShortcut(index + 1)} /> : null}
|
||||
</CommandItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange} filter={fuzzyFilter}>
|
||||
<CommandInput placeholder={t("commandPalette.placeholder")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("commandPalette.empty")}</CommandEmpty>
|
||||
|
||||
<CommandGroup heading={t("commandPalette.groups.navigation")}>
|
||||
{byGroup("navigation").map((s) => {
|
||||
const Icon = ICONS[s.id];
|
||||
return (
|
||||
<CommandItem
|
||||
key={s.id}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onAction(s.id);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
<span>{t(s.labelKey)}</span>
|
||||
<ShortcutTokens shortcut={s} />
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
|
||||
{groupTargets.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading={t("commandPalette.groups.profileGroups")}>
|
||||
{groupTargets.map((target, i) => renderGroup(target, i))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{profiles.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading={t("commandPalette.groups.profiles")}>
|
||||
{profiles.map((p) => {
|
||||
const running = runningProfileIds.has(p.id);
|
||||
return running ? (
|
||||
<CommandItem
|
||||
key={`run-${p.id}`}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onKillProfile(p);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LuCircleStop />
|
||||
<span>
|
||||
{t("commandPalette.actions.stopProfile", {
|
||||
name: p.name,
|
||||
})}
|
||||
</span>
|
||||
</CommandItem>
|
||||
) : (
|
||||
<CommandItem
|
||||
key={`run-${p.id}`}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onLaunchProfile(p);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LuPlay />
|
||||
<span>
|
||||
{t("commandPalette.actions.launchProfile", {
|
||||
name: p.name,
|
||||
})}
|
||||
</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
{profiles.map((p) => (
|
||||
<CommandItem
|
||||
key={`info-${p.id}`}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onShowProfileInfo(p);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LuInfo />
|
||||
<span>
|
||||
{t("commandPalette.actions.profileInfo", { name: p.name })}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading={t("commandPalette.groups.actions")}>
|
||||
{byGroup("actions").map((s) => {
|
||||
const Icon = ICONS[s.id];
|
||||
return (
|
||||
<CommandItem
|
||||
key={s.id}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onAction(s.id);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
<span>{t(s.labelKey)}</span>
|
||||
<ShortcutTokens shortcut={s} />
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export function DeleteConfirmationDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { Extension, ExtensionGroup } from "@/types";
|
||||
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
|
||||
@@ -130,6 +131,8 @@ interface ExtensionManagementDialogProps {
|
||||
onClose: () => void;
|
||||
limitedMode: boolean;
|
||||
subPage?: boolean;
|
||||
/** Which tab is displayed when the dialog mounts; defaults to "extensions". */
|
||||
initialTab?: "extensions" | "groups";
|
||||
}
|
||||
|
||||
export function ExtensionManagementDialog({
|
||||
@@ -137,6 +140,7 @@ export function ExtensionManagementDialog({
|
||||
onClose,
|
||||
limitedMode,
|
||||
subPage,
|
||||
initialTab = "extensions",
|
||||
}: ExtensionManagementDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||
@@ -208,9 +212,10 @@ export function ExtensionManagementDialog({
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
// Tab
|
||||
// Tab — keyed off `initialTab` so remounting the dialog with a new initial
|
||||
// tab (e.g. via the Mod+E shortcut toggle) jumps to that tab.
|
||||
const [activeTab, setActiveTab] = useState<"extensions" | "groups">(
|
||||
"extensions",
|
||||
initialTab,
|
||||
);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -304,7 +309,11 @@ export function ExtensionManagementDialog({
|
||||
);
|
||||
void loadData();
|
||||
} catch (err) {
|
||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
||||
showErrorToast(
|
||||
parseBackendError(err)
|
||||
? translateBackendError(t, err)
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: false }));
|
||||
}
|
||||
@@ -327,7 +336,11 @@ export function ExtensionManagementDialog({
|
||||
);
|
||||
void loadData();
|
||||
} catch (err) {
|
||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
||||
showErrorToast(
|
||||
parseBackendError(err)
|
||||
? translateBackendError(t, err)
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: false }));
|
||||
}
|
||||
@@ -585,9 +598,15 @@ export function ExtensionManagementDialog({
|
||||
}),
|
||||
),
|
||||
);
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
if (failed > 0) {
|
||||
showErrorToast(t("proxies.management.updateSyncFailed"));
|
||||
const firstRejection = results.find((r) => r.status === "rejected") as
|
||||
| PromiseRejectedResult
|
||||
| undefined;
|
||||
if (firstRejection) {
|
||||
showErrorToast(
|
||||
parseBackendError(firstRejection.reason)
|
||||
? translateBackendError(t, firstRejection.reason)
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(
|
||||
targetEnabled
|
||||
@@ -610,9 +629,15 @@ export function ExtensionManagementDialog({
|
||||
}),
|
||||
),
|
||||
);
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
if (failed > 0) {
|
||||
showErrorToast(t("proxies.management.updateSyncFailed"));
|
||||
const firstRejection = results.find((r) => r.status === "rejected") as
|
||||
| PromiseRejectedResult
|
||||
| undefined;
|
||||
if (firstRejection) {
|
||||
showErrorToast(
|
||||
parseBackendError(firstRejection.reason)
|
||||
? translateBackendError(t, firstRejection.reason)
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(
|
||||
targetEnabled
|
||||
@@ -1120,6 +1145,7 @@ export function ExtensionManagementDialog({
|
||||
)}
|
||||
|
||||
<AnimatedTabs
|
||||
key={initialTab}
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "extensions" | "groups")}
|
||||
className="flex-1 min-h-0 flex flex-col"
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { GroupWithCount, ProfileGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
@@ -262,8 +263,8 @@ export function GroupManagementDialog({
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
parseBackendError(error)
|
||||
? translateBackendError(t, error)
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} finally {
|
||||
@@ -529,9 +530,15 @@ export function GroupManagementDialog({
|
||||
}),
|
||||
),
|
||||
);
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
if (failed > 0) {
|
||||
showErrorToast(t("proxies.management.updateSyncFailed"));
|
||||
const firstRejection = results.find((r) => r.status === "rejected") as
|
||||
| PromiseRejectedResult
|
||||
| undefined;
|
||||
if (firstRejection) {
|
||||
showErrorToast(
|
||||
parseBackendError(firstRejection.reason)
|
||||
? translateBackendError(t, firstRejection.reason)
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(
|
||||
targetEnabled
|
||||
|
||||
@@ -62,6 +62,8 @@ interface IntegrationsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
subPage?: boolean;
|
||||
/** Which tab is displayed when the dialog mounts; defaults to "api". */
|
||||
initialTab?: "api" | "mcp";
|
||||
}
|
||||
|
||||
function AgentIcon({ category }: { category: AgentCategory }) {
|
||||
@@ -98,6 +100,7 @@ export function IntegrationsDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
subPage,
|
||||
initialTab = "api",
|
||||
}: IntegrationsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [settings, setSettings] = useState<AppSettings>({
|
||||
@@ -117,6 +120,7 @@ export function IntegrationsDialog({
|
||||
const [isMcpStarting, setIsMcpStarting] = useState(false);
|
||||
const [agents, setAgents] = useState<McpAgentInfo[]>([]);
|
||||
const [busyAgentIds, setBusyAgentIds] = useState<Set<string>>(new Set());
|
||||
const [apiPortDraft, setApiPortDraft] = useState<string>("10108");
|
||||
|
||||
const { termsAccepted } = useWayfernTerms();
|
||||
|
||||
@@ -124,6 +128,7 @@ export function IntegrationsDialog({
|
||||
try {
|
||||
const loaded = await invoke<AppSettings>("get_app_settings");
|
||||
setSettings(loaded);
|
||||
setApiPortDraft(String(loaded.api_port ?? ""));
|
||||
} catch (e) {
|
||||
console.error("Failed to load settings:", e);
|
||||
}
|
||||
@@ -310,7 +315,7 @@ export function IntegrationsDialog({
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto flex-1 min-h-0">
|
||||
<AnimatedTabs defaultValue="api">
|
||||
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="api">
|
||||
{t("integrations.tabApi")}
|
||||
@@ -367,13 +372,24 @@ export function IntegrationsDialog({
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.api_port}
|
||||
value={apiPortDraft}
|
||||
onChange={(e) => {
|
||||
setApiPortDraft(e.target.value);
|
||||
const val = Number.parseInt(e.target.value, 10);
|
||||
if (!Number.isNaN(val)) {
|
||||
if (
|
||||
!Number.isNaN(val) &&
|
||||
val >= 1 &&
|
||||
val <= 65535
|
||||
) {
|
||||
setSettings({ ...settings, api_port: val });
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const val = Number.parseInt(apiPortDraft, 10);
|
||||
if (Number.isNaN(val) || val < 1 || val > 65535) {
|
||||
setApiPortDraft(String(settings.api_port));
|
||||
}
|
||||
}}
|
||||
className="w-24 font-mono"
|
||||
min={1}
|
||||
max={65535}
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = ButtonProps & {
|
||||
export const LoadingButton = ({ isLoading, className, ...props }: Props) => {
|
||||
return (
|
||||
<UIButton
|
||||
className={cn("grid place-items-center", className)}
|
||||
className={cn("inline-flex items-center justify-center", className)}
|
||||
{...props}
|
||||
disabled={props.disabled || isLoading}
|
||||
>
|
||||
|
||||
@@ -691,7 +691,7 @@ const TagsCell = React.memo<{
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-40 h-6 cursor-pointer">
|
||||
<div className="w-full h-6 cursor-pointer">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{ButtonContent}</TooltipTrigger>
|
||||
{hiddenCount > 0 && (
|
||||
@@ -717,7 +717,7 @@ const TagsCell = React.memo<{
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-40 h-6 relative",
|
||||
"w-full h-6 relative",
|
||||
isDisabled && "opacity-60 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
@@ -925,19 +925,17 @@ const NoteCell = React.memo<{
|
||||
}, [openNoteEditorFor, profile.id]);
|
||||
|
||||
const displayNote = effectiveNote ?? "";
|
||||
const trimmedNote =
|
||||
displayNote.length > 12 ? `${displayNote.slice(0, 12)}...` : displayNote;
|
||||
const showTooltip = displayNote.length > 12 || displayNote.length > 0;
|
||||
const showTooltip = displayNote.length > 0;
|
||||
|
||||
if (openNoteEditorFor !== profile.id) {
|
||||
return (
|
||||
<div className="w-24 min-h-6">
|
||||
<div className="w-full min-h-6">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-start px-2 py-1 min-h-6 w-full bg-transparent rounded border-none text-left",
|
||||
"flex items-center px-2 py-1 min-h-6 w-full min-w-0 bg-transparent rounded border-none text-left",
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
@@ -951,11 +949,11 @@ const NoteCell = React.memo<{
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm wrap-break-word",
|
||||
"text-sm truncate block w-full",
|
||||
!effectiveNote && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{effectiveNote ? trimmedNote : t("profiles.note.empty")}
|
||||
{effectiveNote ? displayNote : t("profiles.note.empty")}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -974,7 +972,7 @@ const NoteCell = React.memo<{
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-24 relative",
|
||||
"w-full relative",
|
||||
isDisabled && "opacity-60 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
@@ -1052,6 +1050,13 @@ interface ProfilesDataTableProps {
|
||||
onSetPassword?: (profile: BrowserProfile) => void;
|
||||
onChangePassword?: (profile: BrowserProfile) => void;
|
||||
onRemovePassword?: (profile: BrowserProfile) => void;
|
||||
/**
|
||||
* When provided, the info dialog is controlled by the parent. Allows the
|
||||
* command palette in page.tsx to open the dialog directly without lifting
|
||||
* every other piece of internal table state.
|
||||
*/
|
||||
infoDialogProfile?: BrowserProfile | null;
|
||||
onInfoDialogProfileChange?: (profile: BrowserProfile | null) => void;
|
||||
}
|
||||
|
||||
export function ProfilesDataTable({
|
||||
@@ -1084,6 +1089,8 @@ export function ProfilesDataTable({
|
||||
onSetPassword,
|
||||
onChangePassword,
|
||||
onRemovePassword,
|
||||
infoDialogProfile,
|
||||
onInfoDialogProfileChange,
|
||||
}: ProfilesDataTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
||||
@@ -1155,8 +1162,22 @@ export function ProfilesDataTable({
|
||||
const [profileToDelete, setProfileToDelete] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const [profileForInfoDialog, setProfileForInfoDialog] =
|
||||
const [internalInfoDialogProfile, setInternalInfoDialogProfile] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const isInfoDialogControlled = onInfoDialogProfileChange !== undefined;
|
||||
const profileForInfoDialog = isInfoDialogControlled
|
||||
? (infoDialogProfile ?? null)
|
||||
: internalInfoDialogProfile;
|
||||
const setProfileForInfoDialog = React.useCallback(
|
||||
(p: BrowserProfile | null) => {
|
||||
if (isInfoDialogControlled) {
|
||||
onInfoDialogProfileChange?.(p);
|
||||
} else {
|
||||
setInternalInfoDialogProfile(p);
|
||||
}
|
||||
},
|
||||
[isInfoDialogControlled, onInfoDialogProfileChange],
|
||||
);
|
||||
const [bypassRulesProfile, setBypassRulesProfile] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
|
||||
@@ -2836,7 +2857,7 @@ export function ProfilesDataTable({
|
||||
},
|
||||
},
|
||||
],
|
||||
[t],
|
||||
[t, setProfileForInfoDialog],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
LuShield,
|
||||
LuShieldCheck,
|
||||
LuTrash2,
|
||||
LuUpload,
|
||||
LuUsers,
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
@@ -582,8 +583,9 @@ function ProfileInfoLayout({
|
||||
|
||||
const deleteAction = findAction("delete");
|
||||
const fingerprintAction = findAction("fingerprint");
|
||||
const cookiesAction =
|
||||
findAction("manage cookies") ?? findAction("copy cookies");
|
||||
const cookiesManageAction = findAction("manage cookies");
|
||||
const cookiesCopyAction = findAction("copy cookies");
|
||||
const cookiesAction = cookiesManageAction ?? cookiesCopyAction;
|
||||
const extensionAction = findAction("extension");
|
||||
const syncAction = findAction("sync");
|
||||
const _launchHookAction = findAction("hook") ?? findAction("launch hook");
|
||||
@@ -905,6 +907,8 @@ function ProfileInfoLayout({
|
||||
profile={profile}
|
||||
isRunning={isRunning}
|
||||
isDisabled={isDisabled}
|
||||
onCopyCookies={cookiesCopyAction?.onClick}
|
||||
onImportCookies={cookiesManageAction?.onClick}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
@@ -1435,11 +1439,16 @@ function ExtensionsSectionInline({
|
||||
function CookiesSectionInline({
|
||||
profile,
|
||||
isRunning,
|
||||
isDisabled,
|
||||
onCopyCookies,
|
||||
onImportCookies,
|
||||
t,
|
||||
}: {
|
||||
profile: BrowserProfile;
|
||||
isRunning: boolean;
|
||||
isDisabled: boolean;
|
||||
onCopyCookies?: () => void;
|
||||
onImportCookies?: () => void;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
type CookieStats = {
|
||||
@@ -1483,9 +1492,37 @@ function CookiesSectionInline({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 min-h-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<LuCookie className="size-4" />
|
||||
{t("profileInfo.sections.cookies")}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<LuCookie className="size-4" />
|
||||
{t("profileInfo.sections.cookies")}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{onImportCookies && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5"
|
||||
disabled={isDisabled || isRunning}
|
||||
onClick={onImportCookies}
|
||||
>
|
||||
<LuUpload className="size-3.5" />
|
||||
{t("cookies.import.title")}
|
||||
</Button>
|
||||
)}
|
||||
{onCopyCookies && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5"
|
||||
disabled={isDisabled}
|
||||
onClick={onCopyCookies}
|
||||
>
|
||||
<LuCopy className="size-3.5" />
|
||||
{t("profiles.actions.copyCookies")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("profileInfo.sectionDesc.cookies")}
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types";
|
||||
@@ -394,8 +395,8 @@ export function ProxyManagementDialog({
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
parseBackendError(error)
|
||||
? translateBackendError(t, error)
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} finally {
|
||||
@@ -458,8 +459,8 @@ export function ProxyManagementDialog({
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle VPN sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
parseBackendError(error)
|
||||
? translateBackendError(t, error)
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} finally {
|
||||
@@ -1010,9 +1011,15 @@ export function ProxyManagementDialog({
|
||||
}),
|
||||
),
|
||||
);
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
if (failed > 0) {
|
||||
showErrorToast(t("proxies.management.updateSyncFailed"));
|
||||
const firstRejection = results.find((r) => r.status === "rejected") as
|
||||
| PromiseRejectedResult
|
||||
| undefined;
|
||||
if (firstRejection) {
|
||||
showErrorToast(
|
||||
parseBackendError(firstRejection.reason)
|
||||
? translateBackendError(t, firstRejection.reason)
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(
|
||||
targetEnabled
|
||||
@@ -1039,9 +1046,15 @@ export function ProxyManagementDialog({
|
||||
}),
|
||||
),
|
||||
);
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
if (failed > 0) {
|
||||
showErrorToast(t("vpns.management.updateSyncFailed"));
|
||||
const firstRejection = results.find((r) => r.status === "rejected") as
|
||||
| PromiseRejectedResult
|
||||
| undefined;
|
||||
if (firstRejection) {
|
||||
showErrorToast(
|
||||
parseBackendError(firstRejection.reason)
|
||||
? translateBackendError(t, firstRejection.reason)
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(
|
||||
targetEnabled
|
||||
@@ -1055,7 +1068,7 @@ export function ProxyManagementDialog({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-[min(95vw,1600px)] max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
|
||||
@@ -1170,7 +1183,7 @@ export function ProxyManagementDialog({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table className="min-w-max">
|
||||
<Table className="w-full">
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
{proxiesTable.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
@@ -1251,7 +1264,7 @@ export function ProxyManagementDialog({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table className="min-w-max">
|
||||
<Table className="w-full">
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
{vpnsTable.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
|
||||
@@ -5,7 +5,14 @@ import { useTranslation } from "react-i18next";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal } from "react-icons/go";
|
||||
import { LuCloud, LuPlug, LuPuzzle, LuUser, LuUsers } from "react-icons/lu";
|
||||
import {
|
||||
LuCloud,
|
||||
LuKeyboard,
|
||||
LuPlug,
|
||||
LuPuzzle,
|
||||
LuUser,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "./icons/logo";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
@@ -19,7 +26,8 @@ export type AppPage =
|
||||
| "settings"
|
||||
| "integrations"
|
||||
| "account"
|
||||
| "import";
|
||||
| "import"
|
||||
| "shortcuts";
|
||||
|
||||
const CLICK_THRESHOLD = 5;
|
||||
const CLICK_WINDOW_MS = 2000;
|
||||
@@ -257,6 +265,12 @@ const MORE_ITEMS: MoreMenuItem[] = [
|
||||
labelKey: "rail.more.importProfile",
|
||||
hintKey: "rail.more.importProfileHint",
|
||||
},
|
||||
{
|
||||
page: "shortcuts",
|
||||
Icon: LuKeyboard,
|
||||
labelKey: "rail.more.keyboardShortcuts",
|
||||
hintKey: "rail.more.keyboardShortcutsHint",
|
||||
},
|
||||
];
|
||||
|
||||
export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
|
||||
@@ -464,6 +464,7 @@ export function SettingsDialog({
|
||||
| "fr"
|
||||
| "zh"
|
||||
| "ja"
|
||||
| "ko"
|
||||
| "ru"),
|
||||
);
|
||||
setOriginalLanguage(selectedLanguage);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
formatGroupShortcut,
|
||||
formatShortcut,
|
||||
SHORTCUTS,
|
||||
type ShortcutDef,
|
||||
} from "@/lib/shortcuts";
|
||||
|
||||
interface GroupTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ShortcutsPageProps {
|
||||
/** Ordered list — first 9 entries display their Mod+digit binding. */
|
||||
groupTargets: GroupTarget[];
|
||||
}
|
||||
|
||||
function Tokens({ tokens }: { tokens: string[] }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{tokens.map((tok, i) => (
|
||||
<kbd
|
||||
key={i}
|
||||
className="inline-flex items-center justify-center min-w-[1.5rem] h-6 px-1.5 rounded border border-border bg-muted text-[11px] font-medium text-foreground"
|
||||
>
|
||||
{tok}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutTokens({ shortcut }: { shortcut: ShortcutDef }) {
|
||||
return <Tokens tokens={formatShortcut(shortcut)} />;
|
||||
}
|
||||
|
||||
export function ShortcutsPage({ groupTargets }: ShortcutsPageProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sections: Array<{ key: ShortcutDef["group"]; titleKey: string }> = [
|
||||
{ key: "navigation", titleKey: "commandPalette.groups.navigation" },
|
||||
{ key: "actions", titleKey: "commandPalette.groups.actions" },
|
||||
];
|
||||
|
||||
const digitGroups = groupTargets.slice(0, 9);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto px-6 pt-4 pb-8">
|
||||
<div className="max-w-3xl w-full mx-auto flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold">{t("shortcutsPage.title")}</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("shortcutsPage.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{sections.map(({ key, titleKey }) => {
|
||||
const items = SHORTCUTS.filter((s) => s.group === key);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<section key={key} className="flex flex-col gap-2">
|
||||
<h2 className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t(titleKey)}
|
||||
</h2>
|
||||
<div className="rounded-md border bg-card divide-y divide-border">
|
||||
{items.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-4 px-3 py-2"
|
||||
>
|
||||
<span className="text-sm">{t(s.labelKey)}</span>
|
||||
<ShortcutTokens shortcut={s} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{digitGroups.length > 0 ? (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("commandPalette.groups.profileGroups")}
|
||||
</h2>
|
||||
<div className="rounded-md border bg-card divide-y divide-border">
|
||||
{digitGroups.map((target, i) => (
|
||||
<div
|
||||
key={target.id}
|
||||
className="flex items-center justify-between gap-4 px-3 py-2"
|
||||
>
|
||||
<span className="text-sm">{target.name}</span>
|
||||
<Tokens tokens={formatGroupShortcut(i + 1)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { LuLayers, LuPuzzle, LuShield, LuUsers } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,6 +22,8 @@ interface UnsyncedEntityCounts {
|
||||
proxies: number;
|
||||
groups: number;
|
||||
vpns: number;
|
||||
extensions: number;
|
||||
extension_groups: number;
|
||||
}
|
||||
|
||||
interface SyncAllDialogProps {
|
||||
@@ -67,27 +72,55 @@ export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
|
||||
}
|
||||
}, [onClose, t]);
|
||||
|
||||
const totalCount =
|
||||
(counts?.proxies ?? 0) + (counts?.groups ?? 0) + (counts?.vpns ?? 0);
|
||||
const items = useMemo(() => {
|
||||
if (!counts) return [];
|
||||
return [
|
||||
{
|
||||
key: "proxies",
|
||||
count: counts.proxies,
|
||||
label: t("syncAll.labels.proxies"),
|
||||
Icon: FiWifi,
|
||||
},
|
||||
{
|
||||
key: "vpns",
|
||||
count: counts.vpns,
|
||||
label: t("syncAll.labels.vpns"),
|
||||
Icon: LuShield,
|
||||
},
|
||||
{
|
||||
key: "groups",
|
||||
count: counts.groups,
|
||||
label: t("syncAll.labels.groups"),
|
||||
Icon: LuUsers,
|
||||
},
|
||||
{
|
||||
key: "extensions",
|
||||
count: counts.extensions,
|
||||
label: t("syncAll.labels.extensions"),
|
||||
Icon: LuPuzzle,
|
||||
},
|
||||
{
|
||||
key: "extensionGroups",
|
||||
count: counts.extension_groups,
|
||||
label: t("syncAll.labels.extensionGroups"),
|
||||
Icon: LuLayers,
|
||||
},
|
||||
].filter((item) => item.count > 0);
|
||||
}, [counts, t]);
|
||||
|
||||
// Don't show if there's nothing to sync
|
||||
const totalCount = items.reduce((sum, item) => sum + item.count, 0);
|
||||
|
||||
// Don't render anything when there's nothing to sync — the parent
|
||||
// mounts this dialog eagerly after login, so silent-close is correct.
|
||||
if (!isLoading && totalCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (counts?.proxies && counts.proxies > 0) {
|
||||
parts.push(t("syncAll.proxies", { count: counts.proxies }));
|
||||
}
|
||||
if (counts?.groups && counts.groups > 0) {
|
||||
parts.push(t("syncAll.groups", { count: counts.groups }));
|
||||
}
|
||||
if (counts?.vpns && counts.vpns > 0) {
|
||||
parts.push(t("syncAll.vpns", { count: counts.vpns }));
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen && totalCount > 0} onOpenChange={onClose}>
|
||||
<Dialog
|
||||
open={isOpen && (isLoading || totalCount > 0)}
|
||||
onOpenChange={onClose}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("syncAll.title")}</DialogTitle>
|
||||
@@ -99,10 +132,26 @@ export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
|
||||
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("syncAll.itemsList", { items: parts.join(", ") })}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 py-2">
|
||||
{items.map(({ key, count, label, Icon }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/50 p-3 transition-colors hover:bg-card"
|
||||
>
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-sm font-medium truncate">
|
||||
{label}
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 tabular-nums px-2"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -11,19 +11,18 @@ const MotionThumb = motion.create(SwitchPrimitive.Thumb);
|
||||
type AnimatedSwitchProps = React.ComponentProps<typeof SwitchPrimitive.Root>;
|
||||
|
||||
/**
|
||||
* Toggle switch with a thumb that slides between the off (left) and on
|
||||
* (right) positions and squashes wider while pressed. Animated via Framer
|
||||
* Motion — no layout shift when the parent's width changes, and the
|
||||
* pressed state is purely visual so external onCheckedChange semantics
|
||||
* stay identical to a Radix Switch.
|
||||
* Switch whose thumb actually slides between off and on. The Root flips
|
||||
* its flex alignment on `data-state=checked`, which moves the Thumb's
|
||||
* layout box; Framer Motion's `layout` prop tweens between the two
|
||||
* positions. The thumb also squashes wider while pressed.
|
||||
*/
|
||||
function AnimatedSwitch({ className, ...props }: AnimatedSwitchProps) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="animated-switch"
|
||||
className={cn(
|
||||
"peer relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent",
|
||||
"bg-input data-[state=checked]:bg-primary",
|
||||
"peer relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center justify-start rounded-full border border-transparent px-[2px]",
|
||||
"bg-input data-[state=checked]:bg-primary data-[state=checked]:justify-end",
|
||||
"transition-colors duration-200 ease-out",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
@@ -39,8 +38,7 @@ function AnimatedSwitch({ className, ...props }: AnimatedSwitchProps) {
|
||||
)}
|
||||
layout
|
||||
transition={{ type: "spring", stiffness: 700, damping: 32, mass: 0.5 }}
|
||||
whileTap={{ width: 22 }}
|
||||
style={{ marginLeft: 2, marginRight: 2 }}
|
||||
whileTap={{ width: 20 }}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
|
||||
@@ -34,10 +34,14 @@ function CommandDialog({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
filter,
|
||||
shouldFilter,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
filter?: React.ComponentProps<typeof CommandPrimitive>["filter"];
|
||||
shouldFilter?: React.ComponentProps<typeof CommandPrimitive>["shouldFilter"];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTitle = title ?? t("common.commandPalette.title");
|
||||
@@ -50,7 +54,11 @@ function CommandDialog({
|
||||
<DialogDescription>{resolvedDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
<Command
|
||||
filter={filter}
|
||||
shouldFilter={shouldFilter}
|
||||
className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||
>
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
|
||||
@@ -5,6 +5,7 @@ import en from "./locales/en.json";
|
||||
import es from "./locales/es.json";
|
||||
import fr from "./locales/fr.json";
|
||||
import ja from "./locales/ja.json";
|
||||
import ko from "./locales/ko.json";
|
||||
import pt from "./locales/pt.json";
|
||||
import ru from "./locales/ru.json";
|
||||
import zh from "./locales/zh.json";
|
||||
@@ -16,6 +17,7 @@ export const SUPPORTED_LANGUAGES = [
|
||||
{ code: "fr", name: "French", nativeName: "Français" },
|
||||
{ code: "zh", name: "Chinese", nativeName: "中文" },
|
||||
{ code: "ja", name: "Japanese", nativeName: "日本語" },
|
||||
{ code: "ko", name: "Korean", nativeName: "한국어" },
|
||||
{ code: "ru", name: "Russian", nativeName: "Русский" },
|
||||
] as const;
|
||||
|
||||
@@ -61,6 +63,7 @@ const resources = {
|
||||
fr: { translation: fr },
|
||||
zh: { translation: zh },
|
||||
ja: { translation: ja },
|
||||
ko: { translation: ko },
|
||||
ru: { translation: ru },
|
||||
};
|
||||
|
||||
|
||||
+52
-10
@@ -1070,16 +1070,16 @@
|
||||
"syncAll": {
|
||||
"title": "Enable Sync for Existing Items",
|
||||
"description": "You have items that are not being synced. Would you like to enable sync for all of them?",
|
||||
"itemsList": "Items not synced: {{items}}",
|
||||
"proxies": "{{count}} proxy",
|
||||
"proxies_plural": "{{count}} proxies",
|
||||
"groups": "{{count}} group",
|
||||
"groups_plural": "{{count}} groups",
|
||||
"vpns": "{{count}} VPN",
|
||||
"vpns_plural": "{{count}} VPNs",
|
||||
"enableAll": "Enable All",
|
||||
"skip": "Skip",
|
||||
"success": "Sync enabled for all items"
|
||||
"success": "Sync enabled for all items",
|
||||
"labels": {
|
||||
"proxies": "Proxies",
|
||||
"vpns": "VPNs",
|
||||
"groups": "Groups",
|
||||
"extensions": "Extensions",
|
||||
"extensionGroups": "Extension Groups"
|
||||
}
|
||||
},
|
||||
"crossOs": {
|
||||
"viewOnly": "This profile was created on {{os}} and is not supported on this system",
|
||||
@@ -1788,6 +1788,14 @@
|
||||
"profileLocked": "Profile is locked. Enter the password first.",
|
||||
"invalidProfileId": "Invalid profile id",
|
||||
"passwordTooShort": "Password must be at least {{min}} characters",
|
||||
"proxyNotFound": "Proxy not found",
|
||||
"groupNotFound": "Group not found",
|
||||
"vpnNotFound": "VPN not found",
|
||||
"extensionNotFound": "Extension not found",
|
||||
"extensionGroupNotFound": "Extension group not found",
|
||||
"cannotModifyCloudManagedProxy": "Cannot modify sync for a cloud-managed proxy",
|
||||
"syncLockedByProfile": "Sync cannot be disabled while this is used by synced profiles",
|
||||
"syncNotConfigured": "Sync is not configured. Sign in or configure a self-hosted server first.",
|
||||
"internal": "Something went wrong: {{detail}}",
|
||||
"invalidLaunchHookUrl": "Invalid launch hook URL. Use a full http:// or https:// URL.",
|
||||
"cookieDbLocked": "Could not read cookies — the database is locked. Close the browser and try again.",
|
||||
@@ -1803,7 +1811,9 @@
|
||||
"label": "More",
|
||||
"closeAriaLabel": "Close menu",
|
||||
"importProfile": "Import profile",
|
||||
"importProfileHint": "Bring profiles from another tool"
|
||||
"importProfileHint": "Bring profiles from another tool",
|
||||
"keyboardShortcuts": "Keyboard shortcuts",
|
||||
"keyboardShortcutsHint": "View all shortcuts"
|
||||
},
|
||||
"network": "Network",
|
||||
"integrations": "Integrations",
|
||||
@@ -1817,7 +1827,8 @@
|
||||
"settings": "Settings",
|
||||
"integrations": "Integrations",
|
||||
"account": "Account",
|
||||
"import": "Import profile"
|
||||
"import": "Import profile",
|
||||
"shortcuts": "Keyboard shortcuts"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
@@ -1870,5 +1881,36 @@
|
||||
"testConnection": "Test connection",
|
||||
"disconnect": "Disconnect"
|
||||
}
|
||||
},
|
||||
"shortcutsPage": {
|
||||
"title": "Keyboard shortcuts",
|
||||
"description": "Speed up your workflow with these shortcuts."
|
||||
},
|
||||
"commandPalette": {
|
||||
"placeholder": "Type a command or search...",
|
||||
"empty": "No results found.",
|
||||
"groups": {
|
||||
"navigation": "Navigation",
|
||||
"profiles": "Profiles",
|
||||
"actions": "Actions",
|
||||
"profileGroups": "Profile groups"
|
||||
},
|
||||
"actions": {
|
||||
"launchProfile": "Launch {{name}}",
|
||||
"stopProfile": "Stop {{name}}",
|
||||
"profileInfo": "Info — {{name}}"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"openPalette": "Open command palette",
|
||||
"openShortcuts": "View keyboard shortcuts",
|
||||
"importProfile": "Import profile",
|
||||
"goProfiles": "Go to Profiles",
|
||||
"goProxies": "Go to Network",
|
||||
"goExtensions": "Go to Extensions",
|
||||
"goGroups": "Go to Groups",
|
||||
"goIntegrations": "Go to Integrations",
|
||||
"goAccount": "Go to Account",
|
||||
"goSettings": "Go to Settings"
|
||||
}
|
||||
}
|
||||
|
||||
+52
-10
@@ -1070,16 +1070,16 @@
|
||||
"syncAll": {
|
||||
"title": "Activar sincronización para elementos existentes",
|
||||
"description": "Tienes elementos que no se están sincronizando. ¿Te gustaría activar la sincronización para todos?",
|
||||
"itemsList": "Elementos no sincronizados: {{items}}",
|
||||
"proxies": "{{count}} proxy",
|
||||
"proxies_plural": "{{count}} proxies",
|
||||
"groups": "{{count}} grupo",
|
||||
"groups_plural": "{{count}} grupos",
|
||||
"vpns": "{{count}} VPN",
|
||||
"vpns_plural": "{{count}} VPNs",
|
||||
"enableAll": "Activar todos",
|
||||
"skip": "Omitir",
|
||||
"success": "Sincronización activada para todos los elementos"
|
||||
"success": "Sincronización activada para todos los elementos",
|
||||
"labels": {
|
||||
"proxies": "Proxies",
|
||||
"vpns": "VPN",
|
||||
"groups": "Grupos",
|
||||
"extensions": "Extensiones",
|
||||
"extensionGroups": "Grupos de extensiones"
|
||||
}
|
||||
},
|
||||
"crossOs": {
|
||||
"viewOnly": "Este perfil fue creado en {{os}} y no es compatible con este sistema",
|
||||
@@ -1788,6 +1788,14 @@
|
||||
"profileLocked": "El perfil está bloqueado. Introduce la contraseña primero.",
|
||||
"invalidProfileId": "ID de perfil no válido",
|
||||
"passwordTooShort": "La contraseña debe tener al menos {{min}} caracteres",
|
||||
"proxyNotFound": "Proxy no encontrado",
|
||||
"groupNotFound": "Grupo no encontrado",
|
||||
"vpnNotFound": "VPN no encontrada",
|
||||
"extensionNotFound": "Extensión no encontrada",
|
||||
"extensionGroupNotFound": "Grupo de extensiones no encontrado",
|
||||
"cannotModifyCloudManagedProxy": "No se puede modificar la sincronización de un proxy gestionado en la nube",
|
||||
"syncLockedByProfile": "No se puede desactivar la sincronización mientras se usa en perfiles sincronizados",
|
||||
"syncNotConfigured": "La sincronización no está configurada. Inicia sesión o configura un servidor propio.",
|
||||
"internal": "Algo salió mal: {{detail}}",
|
||||
"invalidLaunchHookUrl": "URL del hook de inicio no válida. Usa una URL completa http:// o https://.",
|
||||
"cookieDbLocked": "No se pudieron leer las cookies — la base de datos está bloqueada. Cierra el navegador e inténtalo de nuevo.",
|
||||
@@ -1803,7 +1811,9 @@
|
||||
"label": "Más",
|
||||
"closeAriaLabel": "Cerrar menú",
|
||||
"importProfile": "Importar perfil",
|
||||
"importProfileHint": "Trae perfiles de otra herramienta"
|
||||
"importProfileHint": "Trae perfiles de otra herramienta",
|
||||
"keyboardShortcuts": "Atajos de teclado",
|
||||
"keyboardShortcutsHint": "Ver todos los atajos"
|
||||
},
|
||||
"network": "Red",
|
||||
"integrations": "Integraciones",
|
||||
@@ -1817,7 +1827,8 @@
|
||||
"settings": "Ajustes",
|
||||
"integrations": "Integraciones",
|
||||
"account": "Cuenta",
|
||||
"import": "Importar perfil"
|
||||
"import": "Importar perfil",
|
||||
"shortcuts": "Atajos de teclado"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
@@ -1870,5 +1881,36 @@
|
||||
"testConnection": "Probar conexión",
|
||||
"disconnect": "Desconectar"
|
||||
}
|
||||
},
|
||||
"shortcutsPage": {
|
||||
"title": "Atajos de teclado",
|
||||
"description": "Agiliza tu flujo de trabajo con estos atajos."
|
||||
},
|
||||
"commandPalette": {
|
||||
"placeholder": "Escribe un comando o busca...",
|
||||
"empty": "No se encontraron resultados.",
|
||||
"groups": {
|
||||
"navigation": "Navegación",
|
||||
"profiles": "Perfiles",
|
||||
"actions": "Acciones",
|
||||
"profileGroups": "Grupos de perfiles"
|
||||
},
|
||||
"actions": {
|
||||
"launchProfile": "Iniciar {{name}}",
|
||||
"stopProfile": "Detener {{name}}",
|
||||
"profileInfo": "Información — {{name}}"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"openPalette": "Abrir paleta de comandos",
|
||||
"openShortcuts": "Ver atajos de teclado",
|
||||
"importProfile": "Importar perfil",
|
||||
"goProfiles": "Ir a Perfiles",
|
||||
"goProxies": "Ir a Red",
|
||||
"goExtensions": "Ir a Extensiones",
|
||||
"goGroups": "Ir a Grupos",
|
||||
"goIntegrations": "Ir a Integraciones",
|
||||
"goAccount": "Ir a Cuenta",
|
||||
"goSettings": "Ir a Configuración"
|
||||
}
|
||||
}
|
||||
|
||||
+52
-10
@@ -1070,16 +1070,16 @@
|
||||
"syncAll": {
|
||||
"title": "Activer la synchronisation pour les éléments existants",
|
||||
"description": "Vous avez des éléments qui ne sont pas synchronisés. Voulez-vous activer la synchronisation pour tous ?",
|
||||
"itemsList": "Éléments non synchronisés : {{items}}",
|
||||
"proxies": "{{count}} proxy",
|
||||
"proxies_plural": "{{count}} proxies",
|
||||
"groups": "{{count}} groupe",
|
||||
"groups_plural": "{{count}} groupes",
|
||||
"vpns": "{{count}} VPN",
|
||||
"vpns_plural": "{{count}} VPNs",
|
||||
"enableAll": "Tout activer",
|
||||
"skip": "Ignorer",
|
||||
"success": "Synchronisation activée pour tous les éléments"
|
||||
"success": "Synchronisation activée pour tous les éléments",
|
||||
"labels": {
|
||||
"proxies": "Proxies",
|
||||
"vpns": "VPN",
|
||||
"groups": "Groupes",
|
||||
"extensions": "Extensions",
|
||||
"extensionGroups": "Groupes d'extensions"
|
||||
}
|
||||
},
|
||||
"crossOs": {
|
||||
"viewOnly": "Ce profil a été créé sur {{os}} et n'est pas pris en charge sur ce système",
|
||||
@@ -1788,6 +1788,14 @@
|
||||
"profileLocked": "Le profil est verrouillé. Entrez d'abord le mot de passe.",
|
||||
"invalidProfileId": "Identifiant de profil non valide",
|
||||
"passwordTooShort": "Le mot de passe doit comporter au moins {{min}} caractères",
|
||||
"proxyNotFound": "Proxy introuvable",
|
||||
"groupNotFound": "Groupe introuvable",
|
||||
"vpnNotFound": "VPN introuvable",
|
||||
"extensionNotFound": "Extension introuvable",
|
||||
"extensionGroupNotFound": "Groupe d'extensions introuvable",
|
||||
"cannotModifyCloudManagedProxy": "Impossible de modifier la synchronisation d'un proxy géré dans le cloud",
|
||||
"syncLockedByProfile": "La synchronisation ne peut pas être désactivée tant qu'elle est utilisée par des profils synchronisés",
|
||||
"syncNotConfigured": "La synchronisation n'est pas configurée. Connectez-vous ou configurez un serveur auto-hébergé.",
|
||||
"internal": "Une erreur s'est produite : {{detail}}",
|
||||
"invalidLaunchHookUrl": "URL du hook de lancement invalide. Utilisez une URL http:// ou https:// complète.",
|
||||
"cookieDbLocked": "Impossible de lire les cookies — la base de données est verrouillée. Fermez le navigateur et réessayez.",
|
||||
@@ -1803,7 +1811,9 @@
|
||||
"label": "Plus",
|
||||
"closeAriaLabel": "Fermer le menu",
|
||||
"importProfile": "Importer un profil",
|
||||
"importProfileHint": "Importer depuis un autre outil"
|
||||
"importProfileHint": "Importer depuis un autre outil",
|
||||
"keyboardShortcuts": "Raccourcis clavier",
|
||||
"keyboardShortcutsHint": "Voir tous les raccourcis"
|
||||
},
|
||||
"network": "Réseau",
|
||||
"integrations": "Intégrations",
|
||||
@@ -1817,7 +1827,8 @@
|
||||
"settings": "Paramètres",
|
||||
"integrations": "Intégrations",
|
||||
"account": "Compte",
|
||||
"import": "Importer un profil"
|
||||
"import": "Importer un profil",
|
||||
"shortcuts": "Raccourcis clavier"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
@@ -1870,5 +1881,36 @@
|
||||
"testConnection": "Tester la connexion",
|
||||
"disconnect": "Déconnecter"
|
||||
}
|
||||
},
|
||||
"shortcutsPage": {
|
||||
"title": "Raccourcis clavier",
|
||||
"description": "Accélérez votre flux de travail avec ces raccourcis."
|
||||
},
|
||||
"commandPalette": {
|
||||
"placeholder": "Tapez une commande ou recherchez...",
|
||||
"empty": "Aucun résultat trouvé.",
|
||||
"groups": {
|
||||
"navigation": "Navigation",
|
||||
"profiles": "Profils",
|
||||
"actions": "Actions",
|
||||
"profileGroups": "Groupes de profils"
|
||||
},
|
||||
"actions": {
|
||||
"launchProfile": "Lancer {{name}}",
|
||||
"stopProfile": "Arrêter {{name}}",
|
||||
"profileInfo": "Informations — {{name}}"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"openPalette": "Ouvrir la palette de commandes",
|
||||
"openShortcuts": "Voir les raccourcis clavier",
|
||||
"importProfile": "Importer un profil",
|
||||
"goProfiles": "Aller à Profils",
|
||||
"goProxies": "Aller à Réseau",
|
||||
"goExtensions": "Aller à Extensions",
|
||||
"goGroups": "Aller à Groupes",
|
||||
"goIntegrations": "Aller à Intégrations",
|
||||
"goAccount": "Aller à Compte",
|
||||
"goSettings": "Aller à Paramètres"
|
||||
}
|
||||
}
|
||||
|
||||
+52
-10
@@ -1070,16 +1070,16 @@
|
||||
"syncAll": {
|
||||
"title": "既存アイテムの同期を有効にする",
|
||||
"description": "同期されていないアイテムがあります。すべての同期を有効にしますか?",
|
||||
"itemsList": "未同期アイテム: {{items}}",
|
||||
"proxies": "{{count}}個のプロキシ",
|
||||
"proxies_plural": "{{count}}個のプロキシ",
|
||||
"groups": "{{count}}個のグループ",
|
||||
"groups_plural": "{{count}}個のグループ",
|
||||
"vpns": "{{count}}個のVPN",
|
||||
"vpns_plural": "{{count}}個のVPN",
|
||||
"enableAll": "すべて有効にする",
|
||||
"skip": "スキップ",
|
||||
"success": "すべてのアイテムの同期が有効になりました"
|
||||
"success": "すべてのアイテムの同期が有効になりました",
|
||||
"labels": {
|
||||
"proxies": "プロキシ",
|
||||
"vpns": "VPN",
|
||||
"groups": "グループ",
|
||||
"extensions": "拡張機能",
|
||||
"extensionGroups": "拡張機能グループ"
|
||||
}
|
||||
},
|
||||
"crossOs": {
|
||||
"viewOnly": "このプロファイルは{{os}}で作成されたもので、このシステムではサポートされていません",
|
||||
@@ -1788,6 +1788,14 @@
|
||||
"profileLocked": "プロファイルはロックされています。先にパスワードを入力してください。",
|
||||
"invalidProfileId": "無効なプロファイルIDです",
|
||||
"passwordTooShort": "パスワードは {{min}} 文字以上必要です",
|
||||
"proxyNotFound": "プロキシが見つかりません",
|
||||
"groupNotFound": "グループが見つかりません",
|
||||
"vpnNotFound": "VPNが見つかりません",
|
||||
"extensionNotFound": "拡張機能が見つかりません",
|
||||
"extensionGroupNotFound": "拡張機能グループが見つかりません",
|
||||
"cannotModifyCloudManagedProxy": "クラウド管理のプロキシの同期は変更できません",
|
||||
"syncLockedByProfile": "同期済みプロファイルで使用中のため、同期を無効にできません",
|
||||
"syncNotConfigured": "同期が設定されていません。サインインするか、セルフホストサーバーを設定してください。",
|
||||
"internal": "問題が発生しました: {{detail}}",
|
||||
"invalidLaunchHookUrl": "起動フックURLが無効です。完全な http:// または https:// URL を使用してください。",
|
||||
"cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。",
|
||||
@@ -1803,7 +1811,9 @@
|
||||
"label": "その他",
|
||||
"closeAriaLabel": "メニューを閉じる",
|
||||
"importProfile": "プロファイルをインポート",
|
||||
"importProfileHint": "別のツールから取り込む"
|
||||
"importProfileHint": "別のツールから取り込む",
|
||||
"keyboardShortcuts": "キーボードショートカット",
|
||||
"keyboardShortcutsHint": "すべてのショートカットを表示"
|
||||
},
|
||||
"network": "ネットワーク",
|
||||
"integrations": "連携",
|
||||
@@ -1817,7 +1827,8 @@
|
||||
"settings": "設定",
|
||||
"integrations": "連携",
|
||||
"account": "アカウント",
|
||||
"import": "プロファイルをインポート"
|
||||
"import": "プロファイルをインポート",
|
||||
"shortcuts": "キーボードショートカット"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
@@ -1870,5 +1881,36 @@
|
||||
"testConnection": "接続をテスト",
|
||||
"disconnect": "切断"
|
||||
}
|
||||
},
|
||||
"shortcutsPage": {
|
||||
"title": "キーボードショートカット",
|
||||
"description": "これらのショートカットでワークフローを高速化できます。"
|
||||
},
|
||||
"commandPalette": {
|
||||
"placeholder": "コマンドを入力するか検索...",
|
||||
"empty": "結果が見つかりませんでした。",
|
||||
"groups": {
|
||||
"navigation": "ナビゲーション",
|
||||
"profiles": "プロファイル",
|
||||
"actions": "アクション",
|
||||
"profileGroups": "プロファイルグループ"
|
||||
},
|
||||
"actions": {
|
||||
"launchProfile": "{{name}} を起動",
|
||||
"stopProfile": "{{name}} を停止",
|
||||
"profileInfo": "情報 — {{name}}"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"openPalette": "コマンドパレットを開く",
|
||||
"openShortcuts": "キーボードショートカットを表示",
|
||||
"importProfile": "プロファイルをインポート",
|
||||
"goProfiles": "プロファイルへ移動",
|
||||
"goProxies": "ネットワークへ移動",
|
||||
"goExtensions": "拡張機能へ移動",
|
||||
"goGroups": "グループへ移動",
|
||||
"goIntegrations": "統合へ移動",
|
||||
"goAccount": "アカウントへ移動",
|
||||
"goSettings": "設定へ移動"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+52
-10
@@ -1070,16 +1070,16 @@
|
||||
"syncAll": {
|
||||
"title": "Ativar sincronização para itens existentes",
|
||||
"description": "Você tem itens que não estão sendo sincronizados. Gostaria de ativar a sincronização para todos?",
|
||||
"itemsList": "Itens não sincronizados: {{items}}",
|
||||
"proxies": "{{count}} proxy",
|
||||
"proxies_plural": "{{count}} proxies",
|
||||
"groups": "{{count}} grupo",
|
||||
"groups_plural": "{{count}} grupos",
|
||||
"vpns": "{{count}} VPN",
|
||||
"vpns_plural": "{{count}} VPNs",
|
||||
"enableAll": "Ativar todos",
|
||||
"skip": "Pular",
|
||||
"success": "Sincronização ativada para todos os itens"
|
||||
"success": "Sincronização ativada para todos os itens",
|
||||
"labels": {
|
||||
"proxies": "Proxies",
|
||||
"vpns": "VPNs",
|
||||
"groups": "Grupos",
|
||||
"extensions": "Extensões",
|
||||
"extensionGroups": "Grupos de extensões"
|
||||
}
|
||||
},
|
||||
"crossOs": {
|
||||
"viewOnly": "Este perfil foi criado em {{os}} e não é compatível com este sistema",
|
||||
@@ -1788,6 +1788,14 @@
|
||||
"profileLocked": "O perfil está bloqueado. Digite a senha primeiro.",
|
||||
"invalidProfileId": "ID de perfil inválido",
|
||||
"passwordTooShort": "A senha deve ter pelo menos {{min}} caracteres",
|
||||
"proxyNotFound": "Proxy não encontrado",
|
||||
"groupNotFound": "Grupo não encontrado",
|
||||
"vpnNotFound": "VPN não encontrada",
|
||||
"extensionNotFound": "Extensão não encontrada",
|
||||
"extensionGroupNotFound": "Grupo de extensões não encontrado",
|
||||
"cannotModifyCloudManagedProxy": "Não é possível modificar a sincronização de um proxy gerenciado na nuvem",
|
||||
"syncLockedByProfile": "A sincronização não pode ser desativada enquanto estiver em uso por perfis sincronizados",
|
||||
"syncNotConfigured": "A sincronização não está configurada. Faça login ou configure um servidor auto-hospedado.",
|
||||
"internal": "Algo deu errado: {{detail}}",
|
||||
"invalidLaunchHookUrl": "URL do hook de inicialização inválida. Use uma URL completa http:// ou https://.",
|
||||
"cookieDbLocked": "Não foi possível ler os cookies — o banco de dados está bloqueado. Feche o navegador e tente novamente.",
|
||||
@@ -1803,7 +1811,9 @@
|
||||
"label": "Mais",
|
||||
"closeAriaLabel": "Fechar menu",
|
||||
"importProfile": "Importar perfil",
|
||||
"importProfileHint": "Trazer perfis de outra ferramenta"
|
||||
"importProfileHint": "Trazer perfis de outra ferramenta",
|
||||
"keyboardShortcuts": "Atalhos de teclado",
|
||||
"keyboardShortcutsHint": "Ver todos os atalhos"
|
||||
},
|
||||
"network": "Rede",
|
||||
"integrations": "Integrações",
|
||||
@@ -1817,7 +1827,8 @@
|
||||
"settings": "Configurações",
|
||||
"integrations": "Integrações",
|
||||
"account": "Conta",
|
||||
"import": "Importar perfil"
|
||||
"import": "Importar perfil",
|
||||
"shortcuts": "Atalhos de teclado"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
@@ -1870,5 +1881,36 @@
|
||||
"testConnection": "Testar conexão",
|
||||
"disconnect": "Desconectar"
|
||||
}
|
||||
},
|
||||
"shortcutsPage": {
|
||||
"title": "Atalhos de teclado",
|
||||
"description": "Acelere seu fluxo de trabalho com estes atalhos."
|
||||
},
|
||||
"commandPalette": {
|
||||
"placeholder": "Digite um comando ou pesquise...",
|
||||
"empty": "Nenhum resultado encontrado.",
|
||||
"groups": {
|
||||
"navigation": "Navegação",
|
||||
"profiles": "Perfis",
|
||||
"actions": "Ações",
|
||||
"profileGroups": "Grupos de perfis"
|
||||
},
|
||||
"actions": {
|
||||
"launchProfile": "Iniciar {{name}}",
|
||||
"stopProfile": "Parar {{name}}",
|
||||
"profileInfo": "Informações — {{name}}"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"openPalette": "Abrir paleta de comandos",
|
||||
"openShortcuts": "Ver atalhos de teclado",
|
||||
"importProfile": "Importar perfil",
|
||||
"goProfiles": "Ir para Perfis",
|
||||
"goProxies": "Ir para Rede",
|
||||
"goExtensions": "Ir para Extensões",
|
||||
"goGroups": "Ir para Grupos",
|
||||
"goIntegrations": "Ir para Integrações",
|
||||
"goAccount": "Ir para Conta",
|
||||
"goSettings": "Ir para Configurações"
|
||||
}
|
||||
}
|
||||
|
||||
+52
-10
@@ -1070,16 +1070,16 @@
|
||||
"syncAll": {
|
||||
"title": "Включить синхронизацию для существующих элементов",
|
||||
"description": "У вас есть элементы, которые не синхронизируются. Хотите включить синхронизацию для всех?",
|
||||
"itemsList": "Несинхронизированные элементы: {{items}}",
|
||||
"proxies": "{{count}} прокси",
|
||||
"proxies_plural": "{{count}} прокси",
|
||||
"groups": "{{count}} группа",
|
||||
"groups_plural": "{{count}} групп",
|
||||
"vpns": "{{count}} VPN",
|
||||
"vpns_plural": "{{count}} VPN",
|
||||
"enableAll": "Включить все",
|
||||
"skip": "Пропустить",
|
||||
"success": "Синхронизация включена для всех элементов"
|
||||
"success": "Синхронизация включена для всех элементов",
|
||||
"labels": {
|
||||
"proxies": "Прокси",
|
||||
"vpns": "VPN",
|
||||
"groups": "Группы",
|
||||
"extensions": "Расширения",
|
||||
"extensionGroups": "Группы расширений"
|
||||
}
|
||||
},
|
||||
"crossOs": {
|
||||
"viewOnly": "Этот профиль был создан на {{os}} и не поддерживается в этой системе",
|
||||
@@ -1788,6 +1788,14 @@
|
||||
"profileLocked": "Профиль заблокирован. Сначала введите пароль.",
|
||||
"invalidProfileId": "Недействительный идентификатор профиля",
|
||||
"passwordTooShort": "Пароль должен быть не короче {{min}} символов",
|
||||
"proxyNotFound": "Прокси не найден",
|
||||
"groupNotFound": "Группа не найдена",
|
||||
"vpnNotFound": "VPN не найден",
|
||||
"extensionNotFound": "Расширение не найдено",
|
||||
"extensionGroupNotFound": "Группа расширений не найдена",
|
||||
"cannotModifyCloudManagedProxy": "Невозможно изменить синхронизацию для облачного прокси",
|
||||
"syncLockedByProfile": "Невозможно отключить синхронизацию, пока используется синхронизированными профилями",
|
||||
"syncNotConfigured": "Синхронизация не настроена. Войдите или настройте собственный сервер.",
|
||||
"internal": "Что-то пошло не так: {{detail}}",
|
||||
"invalidLaunchHookUrl": "Неверный URL хука запуска. Используйте полный URL http:// или https://.",
|
||||
"cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.",
|
||||
@@ -1803,7 +1811,9 @@
|
||||
"label": "Ещё",
|
||||
"closeAriaLabel": "Закрыть меню",
|
||||
"importProfile": "Импорт профиля",
|
||||
"importProfileHint": "Перенести профили из другого инструмента"
|
||||
"importProfileHint": "Перенести профили из другого инструмента",
|
||||
"keyboardShortcuts": "Сочетания клавиш",
|
||||
"keyboardShortcutsHint": "Показать все сочетания"
|
||||
},
|
||||
"network": "Сеть",
|
||||
"integrations": "Интеграции",
|
||||
@@ -1817,7 +1827,8 @@
|
||||
"settings": "Настройки",
|
||||
"integrations": "Интеграции",
|
||||
"account": "Аккаунт",
|
||||
"import": "Импорт профиля"
|
||||
"import": "Импорт профиля",
|
||||
"shortcuts": "Сочетания клавиш"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
@@ -1870,5 +1881,36 @@
|
||||
"testConnection": "Проверить соединение",
|
||||
"disconnect": "Отключить"
|
||||
}
|
||||
},
|
||||
"shortcutsPage": {
|
||||
"title": "Сочетания клавиш",
|
||||
"description": "Ускорьте работу с помощью этих сочетаний клавиш."
|
||||
},
|
||||
"commandPalette": {
|
||||
"placeholder": "Введите команду или поиск...",
|
||||
"empty": "Ничего не найдено.",
|
||||
"groups": {
|
||||
"navigation": "Навигация",
|
||||
"profiles": "Профили",
|
||||
"actions": "Действия",
|
||||
"profileGroups": "Группы профилей"
|
||||
},
|
||||
"actions": {
|
||||
"launchProfile": "Запустить {{name}}",
|
||||
"stopProfile": "Остановить {{name}}",
|
||||
"profileInfo": "Информация — {{name}}"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"openPalette": "Открыть командную палитру",
|
||||
"openShortcuts": "Показать сочетания клавиш",
|
||||
"importProfile": "Импортировать профиль",
|
||||
"goProfiles": "Перейти к Профилям",
|
||||
"goProxies": "Перейти к Сети",
|
||||
"goExtensions": "Перейти к Расширениям",
|
||||
"goGroups": "Перейти к Группам",
|
||||
"goIntegrations": "Перейти к Интеграциям",
|
||||
"goAccount": "Перейти к Аккаунту",
|
||||
"goSettings": "Перейти к Настройкам"
|
||||
}
|
||||
}
|
||||
|
||||
+52
-10
@@ -1070,16 +1070,16 @@
|
||||
"syncAll": {
|
||||
"title": "为现有项目启用同步",
|
||||
"description": "您有未同步的项目。是否要为所有项目启用同步?",
|
||||
"itemsList": "未同步项目: {{items}}",
|
||||
"proxies": "{{count}} 个代理",
|
||||
"proxies_plural": "{{count}} 个代理",
|
||||
"groups": "{{count}} 个分组",
|
||||
"groups_plural": "{{count}} 个分组",
|
||||
"vpns": "{{count}} 个 VPN",
|
||||
"vpns_plural": "{{count}} 个 VPN",
|
||||
"enableAll": "全部启用",
|
||||
"skip": "跳过",
|
||||
"success": "已为所有项目启用同步"
|
||||
"success": "已为所有项目启用同步",
|
||||
"labels": {
|
||||
"proxies": "代理",
|
||||
"vpns": "VPN",
|
||||
"groups": "分组",
|
||||
"extensions": "扩展",
|
||||
"extensionGroups": "扩展分组"
|
||||
}
|
||||
},
|
||||
"crossOs": {
|
||||
"viewOnly": "此配置文件在 {{os}} 上创建,不受此系统支持",
|
||||
@@ -1788,6 +1788,14 @@
|
||||
"profileLocked": "配置文件已锁定。请先输入密码。",
|
||||
"invalidProfileId": "配置文件 ID 无效",
|
||||
"passwordTooShort": "密码至少需要 {{min}} 个字符",
|
||||
"proxyNotFound": "未找到代理",
|
||||
"groupNotFound": "未找到分组",
|
||||
"vpnNotFound": "未找到 VPN",
|
||||
"extensionNotFound": "未找到扩展",
|
||||
"extensionGroupNotFound": "未找到扩展分组",
|
||||
"cannotModifyCloudManagedProxy": "无法修改云管理代理的同步",
|
||||
"syncLockedByProfile": "在被已同步的配置文件使用时无法禁用同步",
|
||||
"syncNotConfigured": "同步未配置。请先登录或配置自托管服务器。",
|
||||
"internal": "出现问题:{{detail}}",
|
||||
"invalidLaunchHookUrl": "启动钩子 URL 无效。请使用完整的 http:// 或 https:// URL。",
|
||||
"cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。",
|
||||
@@ -1803,7 +1811,9 @@
|
||||
"label": "更多",
|
||||
"closeAriaLabel": "关闭菜单",
|
||||
"importProfile": "导入配置文件",
|
||||
"importProfileHint": "从其他工具导入"
|
||||
"importProfileHint": "从其他工具导入",
|
||||
"keyboardShortcuts": "键盘快捷键",
|
||||
"keyboardShortcutsHint": "查看所有快捷键"
|
||||
},
|
||||
"network": "网络",
|
||||
"integrations": "集成",
|
||||
@@ -1817,7 +1827,8 @@
|
||||
"settings": "设置",
|
||||
"integrations": "集成",
|
||||
"account": "账户",
|
||||
"import": "导入配置文件"
|
||||
"import": "导入配置文件",
|
||||
"shortcuts": "键盘快捷键"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
@@ -1870,5 +1881,36 @@
|
||||
"testConnection": "测试连接",
|
||||
"disconnect": "断开连接"
|
||||
}
|
||||
},
|
||||
"shortcutsPage": {
|
||||
"title": "键盘快捷键",
|
||||
"description": "使用这些快捷键加速您的工作流程。"
|
||||
},
|
||||
"commandPalette": {
|
||||
"placeholder": "输入命令或搜索...",
|
||||
"empty": "未找到结果。",
|
||||
"groups": {
|
||||
"navigation": "导航",
|
||||
"profiles": "配置文件",
|
||||
"actions": "操作",
|
||||
"profileGroups": "配置文件分组"
|
||||
},
|
||||
"actions": {
|
||||
"launchProfile": "启动 {{name}}",
|
||||
"stopProfile": "停止 {{name}}",
|
||||
"profileInfo": "信息 — {{name}}"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"openPalette": "打开命令面板",
|
||||
"openShortcuts": "查看键盘快捷键",
|
||||
"importProfile": "导入配置文件",
|
||||
"goProfiles": "转到配置文件",
|
||||
"goProxies": "转到网络",
|
||||
"goExtensions": "转到扩展程序",
|
||||
"goGroups": "转到分组",
|
||||
"goIntegrations": "转到集成",
|
||||
"goAccount": "转到账户",
|
||||
"goSettings": "转到设置"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,14 @@ export type BackendErrorCode =
|
||||
| "COOKIE_DB_LOCKED"
|
||||
| "COOKIE_DB_UNAVAILABLE"
|
||||
| "SELF_HOSTED_REQUIRES_LOGOUT"
|
||||
| "PROXY_NOT_FOUND"
|
||||
| "GROUP_NOT_FOUND"
|
||||
| "VPN_NOT_FOUND"
|
||||
| "EXTENSION_NOT_FOUND"
|
||||
| "EXTENSION_GROUP_NOT_FOUND"
|
||||
| "CANNOT_MODIFY_CLOUD_MANAGED_PROXY"
|
||||
| "SYNC_LOCKED_BY_PROFILE"
|
||||
| "SYNC_NOT_CONFIGURED"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
export interface BackendError {
|
||||
@@ -96,6 +104,22 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.cookieDbUnavailable");
|
||||
case "SELF_HOSTED_REQUIRES_LOGOUT":
|
||||
return t("backendErrors.selfHostedRequiresLogout");
|
||||
case "PROXY_NOT_FOUND":
|
||||
return t("backendErrors.proxyNotFound");
|
||||
case "GROUP_NOT_FOUND":
|
||||
return t("backendErrors.groupNotFound");
|
||||
case "VPN_NOT_FOUND":
|
||||
return t("backendErrors.vpnNotFound");
|
||||
case "EXTENSION_NOT_FOUND":
|
||||
return t("backendErrors.extensionNotFound");
|
||||
case "EXTENSION_GROUP_NOT_FOUND":
|
||||
return t("backendErrors.extensionGroupNotFound");
|
||||
case "CANNOT_MODIFY_CLOUD_MANAGED_PROXY":
|
||||
return t("backendErrors.cannotModifyCloudManagedProxy");
|
||||
case "SYNC_LOCKED_BY_PROFILE":
|
||||
return t("backendErrors.syncLockedByProfile");
|
||||
case "SYNC_NOT_CONFIGURED":
|
||||
return t("backendErrors.syncNotConfigured");
|
||||
case "INTERNAL_ERROR":
|
||||
return t("backendErrors.internal", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Single source of truth for keyboard shortcuts. Each entry declares both how
|
||||
* to MATCH a real keyboard event (lowercase `key` + modifiers) and how to
|
||||
* DISPLAY it to the user. The display side branches on platform so macOS sees
|
||||
* the ⌘ glyph while everyone else sees `Ctrl`.
|
||||
*/
|
||||
|
||||
export type ShortcutGroup =
|
||||
| "navigation"
|
||||
| "actions"
|
||||
| "view"
|
||||
| "profiles"
|
||||
| "groups";
|
||||
|
||||
export interface ShortcutDef {
|
||||
/** Stable identifier — used by the global listener to dispatch to handlers. */
|
||||
id: ShortcutId;
|
||||
/** Translation key for the displayed label in the shortcuts page / palette. */
|
||||
labelKey: string;
|
||||
group: ShortcutGroup;
|
||||
/** Lowercased `KeyboardEvent.key`, e.g. "k", ",", "/". */
|
||||
key: string;
|
||||
/** Require the primary modifier (Cmd on mac, Ctrl elsewhere). */
|
||||
mod?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
}
|
||||
|
||||
export type ShortcutId =
|
||||
| "openPalette"
|
||||
| "openShortcuts"
|
||||
| "importProfile"
|
||||
| "goProfiles"
|
||||
| "goProxies"
|
||||
| "goExtensions"
|
||||
| "goGroups"
|
||||
| "goIntegrations"
|
||||
| "goAccount"
|
||||
| "goSettings";
|
||||
|
||||
export const SHORTCUTS: ShortcutDef[] = [
|
||||
// Actions
|
||||
{
|
||||
id: "openPalette",
|
||||
labelKey: "shortcuts.openPalette",
|
||||
group: "actions",
|
||||
key: "k",
|
||||
mod: true,
|
||||
},
|
||||
{
|
||||
id: "openShortcuts",
|
||||
labelKey: "shortcuts.openShortcuts",
|
||||
group: "actions",
|
||||
key: "/",
|
||||
mod: true,
|
||||
},
|
||||
{
|
||||
id: "importProfile",
|
||||
labelKey: "shortcuts.importProfile",
|
||||
group: "actions",
|
||||
key: "o",
|
||||
mod: true,
|
||||
},
|
||||
// Navigation
|
||||
{
|
||||
id: "goProfiles",
|
||||
labelKey: "shortcuts.goProfiles",
|
||||
group: "navigation",
|
||||
key: "p",
|
||||
mod: true,
|
||||
},
|
||||
{
|
||||
id: "goProxies",
|
||||
labelKey: "shortcuts.goProxies",
|
||||
group: "navigation",
|
||||
key: "n",
|
||||
mod: true,
|
||||
},
|
||||
{
|
||||
id: "goExtensions",
|
||||
labelKey: "shortcuts.goExtensions",
|
||||
group: "navigation",
|
||||
key: "e",
|
||||
mod: true,
|
||||
},
|
||||
{
|
||||
id: "goGroups",
|
||||
labelKey: "shortcuts.goGroups",
|
||||
group: "navigation",
|
||||
key: "g",
|
||||
mod: true,
|
||||
},
|
||||
{
|
||||
id: "goIntegrations",
|
||||
labelKey: "shortcuts.goIntegrations",
|
||||
group: "navigation",
|
||||
key: "i",
|
||||
mod: true,
|
||||
},
|
||||
{
|
||||
id: "goAccount",
|
||||
labelKey: "shortcuts.goAccount",
|
||||
group: "navigation",
|
||||
key: "a",
|
||||
mod: true,
|
||||
},
|
||||
{
|
||||
id: "goSettings",
|
||||
labelKey: "shortcuts.goSettings",
|
||||
group: "navigation",
|
||||
key: ",",
|
||||
mod: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Match Mod+1..9 to the group at that index (1-based). Returns the digit
|
||||
* pressed, or null. Used by the global keydown handler before falling back to
|
||||
* the static SHORTCUTS table.
|
||||
*/
|
||||
export function matchesGroupDigit(e: KeyboardEvent): number | null {
|
||||
if (e.key < "1" || e.key > "9") return null;
|
||||
const mod = isMac() ? e.metaKey : e.ctrlKey;
|
||||
const oppositeMod = isMac() ? e.ctrlKey : e.metaKey;
|
||||
if (!mod || oppositeMod || e.shiftKey || e.altKey) return null;
|
||||
return Number(e.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build display tokens for a Mod+digit group shortcut. Mirrors `formatShortcut`.
|
||||
*/
|
||||
export function formatGroupShortcut(digit: number): string[] {
|
||||
const mac = isMac();
|
||||
return [mac ? "⌘" : "Ctrl", String(digit)];
|
||||
}
|
||||
|
||||
export function isMac(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
// userAgentData is preferred but not in all browsers; fall back to platform.
|
||||
// `navigator.platform` is deprecated but still works in Tauri's webview.
|
||||
const ua = navigator.userAgent || "";
|
||||
const platform =
|
||||
(navigator as unknown as { userAgentData?: { platform?: string } })
|
||||
.userAgentData?.platform ??
|
||||
navigator.platform ??
|
||||
"";
|
||||
return /Mac|iPhone|iPad|iPod/.test(platform) || /Mac OS X/.test(ua);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a shortcut as the platform-correct token list. The shortcuts page and
|
||||
* the command palette both consume this so the glyphs stay in sync.
|
||||
*
|
||||
* On macOS: ["⌘", "⇧", "⌥", "K"]
|
||||
* Elsewhere: ["Ctrl", "Shift", "Alt", "K"]
|
||||
*/
|
||||
export function formatShortcut(s: ShortcutDef): string[] {
|
||||
const mac = isMac();
|
||||
const tokens: string[] = [];
|
||||
if (s.mod) tokens.push(mac ? "⌘" : "Ctrl");
|
||||
if (s.shift) tokens.push(mac ? "⇧" : "Shift");
|
||||
if (s.alt) tokens.push(mac ? "⌥" : "Alt");
|
||||
tokens.push(prettyKey(s.key));
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function prettyKey(key: string): string {
|
||||
if (key.length === 1) return key.toUpperCase();
|
||||
// Named keys like "Enter", "Escape", etc. would already be capitalized.
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a real `KeyboardEvent` against a shortcut definition. Returns true
|
||||
* only when modifiers are an exact match (so Ctrl+Shift+K doesn't fire
|
||||
* Ctrl+K).
|
||||
*/
|
||||
export function matchesShortcut(s: ShortcutDef, e: KeyboardEvent): boolean {
|
||||
if (e.key.toLowerCase() !== s.key.toLowerCase()) return false;
|
||||
const mod = isMac() ? e.metaKey : e.ctrlKey;
|
||||
const oppositeMod = isMac() ? e.ctrlKey : e.metaKey;
|
||||
if (Boolean(s.mod) !== mod) return false;
|
||||
// Reject the wrong-platform modifier so Ctrl+K on macOS doesn't accidentally
|
||||
// trigger something that only expects ⌘+K.
|
||||
if (oppositeMod) return false;
|
||||
if (Boolean(s.shift) !== e.shiftKey) return false;
|
||||
if (Boolean(s.alt) !== e.altKey) return false;
|
||||
return true;
|
||||
}
|
||||
+11
-1
@@ -1,3 +1,4 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import React from "react";
|
||||
import { type ExternalToast, toast as sonnerToast } from "sonner";
|
||||
import { UnifiedToast } from "@/components/custom-toast";
|
||||
@@ -259,7 +260,7 @@ export function showSyncProgressToast(
|
||||
failed_count: number;
|
||||
phase: string;
|
||||
},
|
||||
options?: { id?: string },
|
||||
options?: { id?: string; profileId?: string },
|
||||
) {
|
||||
return showToast({
|
||||
type: "sync-progress",
|
||||
@@ -268,6 +269,15 @@ export function showSyncProgressToast(
|
||||
id: options?.id,
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
onCancel: () => {
|
||||
if (options?.profileId) {
|
||||
// Fire-and-forget — backend flips the cancel flag for the in-flight
|
||||
// upload/download loops to drain.
|
||||
void invoke("cancel_profile_sync", {
|
||||
profileId: options.profileId,
|
||||
}).catch((err: unknown) => {
|
||||
console.error("Failed to cancel sync:", err);
|
||||
});
|
||||
}
|
||||
if (options?.id) {
|
||||
dismissToast(options.id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user