mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-12 17:57:50 +02:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
|
||||||
- name: Set up pnpm package manager
|
- name: Set up pnpm package manager
|
||||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
with:
|
with:
|
||||||
run_install: false
|
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:
|
security-scan:
|
||||||
name: Security Vulnerability Scan
|
name: Security Vulnerability Scan
|
||||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
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:
|
with:
|
||||||
scan-args: |-
|
scan-args: |-
|
||||||
-r
|
-r
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- 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
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 #v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee #v4.2.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
echo "Tags: ${TAGS}"
|
echo "Tags: ${TAGS}"
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- 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:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./donut-sync/Dockerfile
|
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:
|
env:
|
||||||
# Single source of truth for the model used by both triage and composer.
|
# Single source of truth for the model used by both triage and composer.
|
||||||
TRIAGE_MODEL: anthropic/claude-opus-4.7
|
TRIAGE_MODEL: z-ai/glm-5.1
|
||||||
COMPOSER_MODEL: anthropic/claude-opus-4.7
|
COMPOSER_MODEL: z-ai/glm-5.1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze-issue:
|
analyze-issue:
|
||||||
@@ -102,12 +102,14 @@ jobs:
|
|||||||
its API, MCP server, and the bundled `donut-sync` self-hosted server.
|
its API, MCP server, and the bundled `donut-sync` self-hosted server.
|
||||||
- **Wayfern** — a Chromium fork maintained by zhom (the same maintainer). Wayfern
|
- **Wayfern** — a Chromium fork maintained by zhom (the same maintainer). Wayfern
|
||||||
bugs are in-scope here unless they are obviously upstream Chromium issues.
|
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
|
- **Camoufox** — a Firefox fork by daijro, used by Donut but maintained in a
|
||||||
contribute to Camoufox and CANNOT fix bugs in it.
|
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,
|
- Bugs about Camoufox's *internal* behavior (page rendering, JS engine,
|
||||||
dropdowns, form widgets, fingerprinting *as Camoufox implements it*,
|
dropdowns, form widgets, fingerprinting *as Camoufox implements it*,
|
||||||
checkbox/radio quirks) are UPSTREAM ONLY. Redirect to
|
checkbox/radio quirks) are out of scope here. Ask the user to first
|
||||||
https://github.com/daijro/camoufox/issues.
|
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
|
- Bugs about how Donut *launches, configures, or downloads* Camoufox are
|
||||||
in-scope here.
|
in-scope here.
|
||||||
- **Forks of Wayfern or Camoufox** (e.g. CloverLabsAI, VulpineOS) are NOT
|
- **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
|
dismiss as "known issue" / "expected" / "false positive in Tauri apps". Ask
|
||||||
which exact version was the last working one and what changed.
|
which exact version was the last working one and what changed.
|
||||||
- **Out-of-scope (upstream Camoufox)**: report is about Camoufox's own
|
- **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
|
- **Fork-support request**: asks the maintainer to support an alternative
|
||||||
Wayfern/Camoufox fork. Acknowledge in one neutral sentence — do NOT call it
|
Wayfern/Camoufox fork. Acknowledge in one neutral sentence — do NOT call it
|
||||||
"clear", "reasonable", "well-thought-out", etc.
|
"clear", "reasonable", "well-thought-out", etc.
|
||||||
@@ -342,7 +347,7 @@ jobs:
|
|||||||
The triage classification (`triage.classification`) determines the response shape:
|
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-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.
|
- `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.
|
- `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.
|
- `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
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
|
||||||
- name: Run opencode
|
- name: Run opencode
|
||||||
uses: anomalyco/opencode/github@8ba2a9171597262df9d19516c82a5e14f18f5c63 #v1.14.41
|
uses: anomalyco/opencode/github@d74d166acf40e51146f8547216913a4e787a4bc1 #v1.15.10
|
||||||
env:
|
env:
|
||||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
|
||||||
- name: Set up pnpm package manager
|
- name: Set up pnpm package manager
|
||||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
|
||||||
- name: Set up pnpm package manager
|
- name: Set up pnpm package manager
|
||||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
models: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
notify:
|
notify:
|
||||||
@@ -105,21 +106,12 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
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'
|
if: steps.gate.outputs.skip != 'true'
|
||||||
env:
|
env:
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
|
||||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
|
||||||
TAG: ${{ steps.tag.outputs.tag }}
|
TAG: ${{ steps.tag.outputs.tag }}
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
run: |
|
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 \
|
PREV_TAG=$(git tag --sort=-version:refname \
|
||||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||||
| grep -v "^${TAG}$" \
|
| grep -v "^${TAG}$" \
|
||||||
@@ -127,29 +119,52 @@ jobs:
|
|||||||
if [ -z "$PREV_TAG" ]; then
|
if [ -z "$PREV_TAG" ]; then
|
||||||
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
|
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
|
||||||
fi
|
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.
|
- name: Post release announcement to Telegram
|
||||||
# Other commit types (chore, docs, ci, test, deps) are intentionally
|
if: steps.gate.outputs.skip != 'true'
|
||||||
# filtered out to keep the channel focused on user-visible changes.
|
env:
|
||||||
CHANGES=""
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
while IFS= read -r msg; do
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
[ -z "$msg" ] && continue
|
TAG: ${{ steps.tag.outputs.tag }}
|
||||||
case "$msg" in
|
REPO: ${{ github.repository }}
|
||||||
feat\(*\):*|feat:*|fix\(*\):*|fix:*|refactor\(*\):*|refactor:*)
|
AI_RESPONSE_FILE: ${{ steps.ai.outputs.response-file }}
|
||||||
CHANGES="${CHANGES}• $(strip_prefix "$msg")"$'\n'
|
AI_RESPONSE: ${{ steps.ai.outputs.response }}
|
||||||
;;
|
run: |
|
||||||
esac
|
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||||
done < <(git log --pretty=format:%s "${PREV_TAG}..${TAG}")
|
echo "::warning::TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set — skipping Telegram notification."
|
||||||
|
exit 0
|
||||||
if [ -z "$CHANGES" ]; then
|
|
||||||
CHANGES="• See release notes."$'\n'
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# HTML-escape the changelog before injecting into Telegram HTML
|
# Prefer the file output — `response` can be truncated for longer summaries.
|
||||||
# mode — commit messages can legitimately contain `<`, `>`, `&`.
|
if [ -n "$AI_RESPONSE_FILE" ] && [ -f "$AI_RESPONSE_FILE" ]; then
|
||||||
ESCAPED_CHANGES=$(printf '%s' "$CHANGES" \
|
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()))")
|
| python3 -c "import html, sys; sys.stdout.write(html.escape(sys.stdin.read()))")
|
||||||
|
|
||||||
VERSION="${TAG}"
|
VERSION="${TAG}"
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ jobs:
|
|||||||
scan-scheduled:
|
scan-scheduled:
|
||||||
name: Scheduled Security Scan
|
name: Scheduled Security Scan
|
||||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
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:
|
with:
|
||||||
scan-args: |-
|
scan-args: |-
|
||||||
-r
|
-r
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
scan-pr:
|
scan-pr:
|
||||||
name: PR Security Scan
|
name: PR Security Scan
|
||||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
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:
|
with:
|
||||||
scan-args: |-
|
scan-args: |-
|
||||||
-r
|
-r
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
security-scan:
|
security-scan:
|
||||||
name: Security Vulnerability Scan
|
name: Security Vulnerability Scan
|
||||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
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:
|
with:
|
||||||
scan-args: |-
|
scan-args: |-
|
||||||
-r
|
-r
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ jobs:
|
|||||||
- name: Generate release notes with AI
|
- name: Generate release notes with AI
|
||||||
id: generate-notes
|
id: generate-notes
|
||||||
if: steps.get-release.outputs.is-prerelease == 'false'
|
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:
|
with:
|
||||||
prompt-file: .github/prompts/release-notes.prompt.yml
|
prompt-file: .github/prompts/release-notes.prompt.yml
|
||||||
input: |
|
input: |
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
security-scan:
|
security-scan:
|
||||||
if: github.repository == 'zhom/donutbrowser'
|
if: github.repository == 'zhom/donutbrowser'
|
||||||
name: Security Vulnerability Scan
|
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:
|
with:
|
||||||
scan-args: |-
|
scan-args: |-
|
||||||
-r
|
-r
|
||||||
@@ -108,7 +108,7 @@ jobs:
|
|||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
security-scan:
|
security-scan:
|
||||||
if: github.repository == 'zhom/donutbrowser'
|
if: github.repository == 'zhom/donutbrowser'
|
||||||
name: Security Vulnerability Scan
|
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:
|
with:
|
||||||
scan-args: |-
|
scan-args: |-
|
||||||
-r
|
-r
|
||||||
@@ -107,7 +107,7 @@ jobs:
|
|||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
|||||||
@@ -23,4 +23,4 @@ jobs:
|
|||||||
- name: Checkout Actions Repository
|
- name: Checkout Actions Repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
- name: Spell Check Repo
|
- 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
|
pull-requests: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open."
|
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
|
uses: actions/checkout@v6.0.2
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ donutbrowser/
|
|||||||
- After making changes, run `pnpm format && pnpm lint && pnpm test` at the root of the project
|
- 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
|
- 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`
|
- `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
|
## 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.
|
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
|
## Singletons
|
||||||
|
|
||||||
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
|
- 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
|
# 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)
|
## v0.24.1 (2026-05-12)
|
||||||
|
|
||||||
### Refactoring
|
### Refactoring
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
|
|
||||||
| | Apple Silicon | Intel |
|
| | 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:
|
Or install via Homebrew:
|
||||||
|
|
||||||
@@ -58,15 +58,15 @@ brew install --cask donut
|
|||||||
|
|
||||||
### Windows
|
### 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
|
### Linux
|
||||||
|
|
||||||
| Format | x86_64 | ARM64 |
|
| 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) |
|
| **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.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) |
|
| **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.1/Donut_0.24.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_aarch64.AppImage) |
|
| **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 -->
|
<!-- install-links-end -->
|
||||||
|
|
||||||
Or install via package manager:
|
Or install via package manager:
|
||||||
|
|||||||
@@ -94,17 +94,17 @@
|
|||||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||||
);
|
);
|
||||||
releaseVersion = "0.24.1";
|
releaseVersion = "0.24.2";
|
||||||
releaseAppImage =
|
releaseAppImage =
|
||||||
if system == "x86_64-linux" then
|
if system == "x86_64-linux" then
|
||||||
pkgs.fetchurl {
|
pkgs.fetchurl {
|
||||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_amd64.AppImage";
|
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_amd64.AppImage";
|
||||||
hash = "sha256-nJ4WmbXQcnXWDaneucOlwzZmlOOBx+G/qDeCHH6/Vno=";
|
hash = "sha256-140PSB/1BLGUB4sI/RgfYe7uUjwRFWXtdSnUZz6Wr0U=";
|
||||||
}
|
}
|
||||||
else if system == "aarch64-linux" then
|
else if system == "aarch64-linux" then
|
||||||
pkgs.fetchurl {
|
pkgs.fetchurl {
|
||||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_aarch64.AppImage";
|
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_aarch64.AppImage";
|
||||||
hash = "sha256-aLzHAdn+o9YsnKtK5BpjjrzAAbp/itsN1QdELTpHyTQ=";
|
hash = "sha256-QPGV6XO0ugPAJSbPJrVwDsEb9lw3dcL6IdU17UCYH4E=";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
null;
|
null;
|
||||||
|
|||||||
+2
-12
@@ -2,7 +2,7 @@
|
|||||||
"name": "donutbrowser",
|
"name": "donutbrowser",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"version": "0.24.2",
|
"version": "0.24.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 12341",
|
"dev": "next dev --turbopack -p 12341",
|
||||||
@@ -89,17 +89,7 @@
|
|||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"packageManager": "pnpm@11.2.2",
|
||||||
"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",
|
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"**/*.{js,jsx,ts,tsx,json,css}": [
|
"**/*.{js,jsx,ts,tsx,json,css}": [
|
||||||
"biome check --fix"
|
"biome check --fix"
|
||||||
|
|||||||
Generated
+27
-26
@@ -11,6 +11,8 @@ overrides:
|
|||||||
fast-xml-parser@<5.7.0: '>=5.7.2'
|
fast-xml-parser@<5.7.0: '>=5.7.2'
|
||||||
fast-uri@<3.1.2: '>=3.1.2'
|
fast-uri@<3.1.2: '>=3.1.2'
|
||||||
fast-xml-builder@<1.2.0: '>=1.2.0'
|
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:
|
importers:
|
||||||
|
|
||||||
@@ -212,7 +214,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@nestjs/cli':
|
'@nestjs/cli':
|
||||||
specifier: ^11.0.21
|
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':
|
'@nestjs/schematics':
|
||||||
specifier: ^11.1.0
|
specifier: ^11.1.0
|
||||||
version: 11.1.0(chokidar@4.0.3)(typescript@6.0.3)
|
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)
|
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:
|
ts-loader:
|
||||||
specifier: ^9.5.7
|
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:
|
ts-node:
|
||||||
specifier: ^10.9.2
|
specifier: ^10.9.2
|
||||||
version: 10.9.2(@types/node@25.7.0)(typescript@6.0.3)
|
version: 10.9.2(@types/node@25.7.0)(typescript@6.0.3)
|
||||||
@@ -2060,6 +2062,7 @@ packages:
|
|||||||
'@smithy/core@3.24.1':
|
'@smithy/core@3.24.1':
|
||||||
resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==}
|
resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==}
|
||||||
engines: {node: '>=18.0.0'}
|
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':
|
'@smithy/credential-provider-imds@4.3.1':
|
||||||
resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==}
|
resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==}
|
||||||
@@ -3872,9 +3875,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
js-cookie@3.0.5:
|
js-cookie@3.0.7:
|
||||||
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
|
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
@@ -4401,8 +4404,8 @@ packages:
|
|||||||
pure-rand@7.0.1:
|
pure-rand@7.0.1:
|
||||||
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
|
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
|
||||||
|
|
||||||
qs@6.15.1:
|
qs@6.15.2:
|
||||||
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
|
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
|
|
||||||
radix-ui@1.4.3:
|
radix-ui@1.4.3:
|
||||||
@@ -6421,7 +6424,7 @@ snapshots:
|
|||||||
'@tybys/wasm-util': 0.10.2
|
'@tybys/wasm-util': 0.10.2
|
||||||
optional: true
|
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:
|
dependencies:
|
||||||
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
|
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
|
||||||
'@angular-devkit/schematics': 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
|
chokidar: 4.0.3
|
||||||
cli-table3: 0.6.5
|
cli-table3: 0.6.5
|
||||||
commander: 4.1.1
|
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
|
glob: 13.0.6
|
||||||
node-emoji: 1.11.0
|
node-emoji: 1.11.0
|
||||||
ora: 5.4.1
|
ora: 5.4.1
|
||||||
tsconfig-paths: 4.2.0
|
tsconfig-paths: 4.2.0
|
||||||
tsconfig-paths-webpack-plugin: 4.2.0
|
tsconfig-paths-webpack-plugin: 4.2.0
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
webpack: 5.106.0(lightningcss@1.32.0)
|
webpack: 5.106.0
|
||||||
webpack-node-externals: 3.0.0
|
webpack-node-externals: 3.0.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@minify-html/node'
|
- '@minify-html/node'
|
||||||
@@ -8125,7 +8128,7 @@ snapshots:
|
|||||||
'@types/js-cookie': 3.0.6
|
'@types/js-cookie': 3.0.6
|
||||||
dayjs: 1.11.20
|
dayjs: 1.11.20
|
||||||
intersection-observer: 0.12.2
|
intersection-observer: 0.12.2
|
||||||
js-cookie: 3.0.5
|
js-cookie: 3.0.7
|
||||||
lodash: 4.18.1
|
lodash: 4.18.1
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
@@ -8295,7 +8298,7 @@ snapshots:
|
|||||||
http-errors: 2.0.1
|
http-errors: 2.0.1
|
||||||
iconv-lite: 0.7.2
|
iconv-lite: 0.7.2
|
||||||
on-finished: 2.4.1
|
on-finished: 2.4.1
|
||||||
qs: 6.15.1
|
qs: 6.15.2
|
||||||
raw-body: 3.0.2
|
raw-body: 3.0.2
|
||||||
type-is: 2.0.1
|
type-is: 2.0.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -8733,7 +8736,7 @@ snapshots:
|
|||||||
once: 1.4.0
|
once: 1.4.0
|
||||||
parseurl: 1.3.3
|
parseurl: 1.3.3
|
||||||
proxy-addr: 2.0.7
|
proxy-addr: 2.0.7
|
||||||
qs: 6.15.1
|
qs: 6.15.2
|
||||||
range-parser: 1.2.1
|
range-parser: 1.2.1
|
||||||
router: 2.2.0
|
router: 2.2.0
|
||||||
send: 1.2.1
|
send: 1.2.1
|
||||||
@@ -8804,7 +8807,7 @@ snapshots:
|
|||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
signal-exit: 4.1.0
|
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:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.0
|
'@babel/code-frame': 7.29.0
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
@@ -8819,7 +8822,7 @@ snapshots:
|
|||||||
semver: 7.8.0
|
semver: 7.8.0
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
webpack: 5.106.0(lightningcss@1.32.0)
|
webpack: 5.106.0
|
||||||
|
|
||||||
form-data@4.0.5:
|
form-data@4.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9382,7 +9385,7 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.7.0: {}
|
jiti@2.7.0: {}
|
||||||
|
|
||||||
js-cookie@3.0.5: {}
|
js-cookie@3.0.7: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
@@ -9834,7 +9837,7 @@ snapshots:
|
|||||||
|
|
||||||
pure-rand@7.0.1: {}
|
pure-rand@7.0.1: {}
|
||||||
|
|
||||||
qs@6.15.1:
|
qs@6.15.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel: 1.1.0
|
side-channel: 1.1.0
|
||||||
|
|
||||||
@@ -10294,7 +10297,7 @@ snapshots:
|
|||||||
formidable: 3.5.4
|
formidable: 3.5.4
|
||||||
methods: 1.1.2
|
methods: 1.1.2
|
||||||
mime: 2.6.0
|
mime: 2.6.0
|
||||||
qs: 6.15.1
|
qs: 6.15.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -10330,15 +10333,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.11.0
|
'@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:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
jest-worker: 27.5.1
|
jest-worker: 27.5.1
|
||||||
schema-utils: 4.3.3
|
schema-utils: 4.3.3
|
||||||
terser: 5.47.1
|
terser: 5.47.1
|
||||||
webpack: 5.106.0(lightningcss@1.32.0)
|
webpack: 5.106.0
|
||||||
optionalDependencies:
|
|
||||||
lightningcss: 1.32.0
|
|
||||||
|
|
||||||
terser@5.47.1:
|
terser@5.47.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -10391,7 +10392,7 @@ snapshots:
|
|||||||
babel-jest: 30.4.1(@babel/core@7.29.0)
|
babel-jest: 30.4.1(@babel/core@7.29.0)
|
||||||
jest-util: 30.4.1
|
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:
|
dependencies:
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
enhanced-resolve: 5.21.3
|
enhanced-resolve: 5.21.3
|
||||||
@@ -10399,7 +10400,7 @@ snapshots:
|
|||||||
semver: 7.8.0
|
semver: 7.8.0
|
||||||
source-map: 0.7.6
|
source-map: 0.7.6
|
||||||
typescript: 6.0.3
|
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):
|
ts-node@10.9.2(@types/node@25.7.0)(typescript@6.0.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -10588,7 +10589,7 @@ snapshots:
|
|||||||
|
|
||||||
webpack-sources@3.4.1: {}
|
webpack-sources@3.4.1: {}
|
||||||
|
|
||||||
webpack@5.106.0(lightningcss@1.32.0):
|
webpack@5.106.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/eslint-scope': 3.7.7
|
'@types/eslint-scope': 3.7.7
|
||||||
'@types/estree': 1.0.9
|
'@types/estree': 1.0.9
|
||||||
@@ -10612,7 +10613,7 @@ snapshots:
|
|||||||
neo-async: 2.6.2
|
neo-async: 2.6.2
|
||||||
schema-utils: 4.3.3
|
schema-utils: 4.3.3
|
||||||
tapable: 2.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
|
watchpack: 2.5.1
|
||||||
webpack-sources: 3.4.1
|
webpack-sources: 3.4.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|||||||
@@ -11,3 +11,25 @@ onlyBuiltDependencies:
|
|||||||
- sharp
|
- sharp
|
||||||
- sqlite3
|
- sqlite3
|
||||||
- unrs-resolver
|
- 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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8"
|
checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cipher 0.5.1",
|
"cipher 0.5.2",
|
||||||
"cpubits",
|
"cpubits",
|
||||||
"cpufeatures 0.3.0",
|
"cpufeatures 0.3.0",
|
||||||
]
|
]
|
||||||
@@ -169,7 +169,7 @@ version = "1.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -180,7 +180,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -445,9 +445,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.0"
|
version = "1.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "av-scenechange"
|
name = "av-scenechange"
|
||||||
@@ -785,15 +785,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "built"
|
name = "built"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64"
|
checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.2"
|
version = "3.20.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byte-unit"
|
name = "byte-unit"
|
||||||
@@ -962,11 +962,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cbc"
|
name = "cbc"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "98db6aeaef0eeef2c1e3ce9a27b739218825dae116076352ac3777076aa22225"
|
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cipher 0.5.1",
|
"cipher 0.5.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1103,11 +1103,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cipher"
|
name = "cipher"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea"
|
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crypto-common 0.2.1",
|
"crypto-common 0.2.2",
|
||||||
"inout 0.2.2",
|
"inout 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1405,9 +1405,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
]
|
]
|
||||||
@@ -1679,7 +1679,7 @@ checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.12.0",
|
"block-buffer 0.12.0",
|
||||||
"const-oid 0.10.2",
|
"const-oid 0.10.2",
|
||||||
"crypto-common 0.2.1",
|
"crypto-common 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1709,7 +1709,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users",
|
"redox_users",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1784,7 +1784,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "donutbrowser"
|
name = "donutbrowser"
|
||||||
version = "0.24.2"
|
version = "0.24.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes 0.9.0",
|
"aes 0.9.0",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -1824,7 +1824,7 @@ dependencies = [
|
|||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"playwright",
|
"playwright",
|
||||||
"quick-xml",
|
"quick-xml 0.40.1",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"regex-lite",
|
"regex-lite",
|
||||||
"reqwest 0.13.3",
|
"reqwest 0.13.3",
|
||||||
@@ -1858,7 +1858,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"toml 0.9.12+spec-1.1.0",
|
"toml 1.1.2+spec-1.1.0",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tray-icon 0.24.0",
|
"tray-icon 0.24.0",
|
||||||
@@ -1962,9 +1962,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.16.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embed-resource"
|
name = "embed-resource"
|
||||||
@@ -2099,7 +2099,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2213,9 +2213,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filetime"
|
name = "filetime"
|
||||||
version = "0.2.28"
|
version = "0.2.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6"
|
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -3087,7 +3087,7 @@ dependencies = [
|
|||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"windows-core 0.62.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3554,12 +3554,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kurbo"
|
name = "kurbo"
|
||||||
version = "0.13.0"
|
version = "0.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
|
checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"euclid",
|
"euclid",
|
||||||
|
"polycool",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3914,9 +3915,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb"
|
checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dpi",
|
"dpi",
|
||||||
@@ -3931,7 +3932,7 @@ dependencies = [
|
|||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4038,9 +4039,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-derive"
|
name = "num-derive"
|
||||||
@@ -4382,9 +4383,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "open"
|
name = "open"
|
||||||
version = "5.3.4"
|
version = "5.3.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd"
|
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dunce",
|
"dunce",
|
||||||
"is-wsl",
|
"is-wsl",
|
||||||
@@ -4394,9 +4395,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.79"
|
version = "0.10.80"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542"
|
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
@@ -4425,9 +4426,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-sys"
|
name = "openssl-sys"
|
||||||
version = "0.9.115"
|
version = "0.9.116"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781"
|
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -4468,7 +4469,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.45.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4660,18 +4661,18 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project"
|
name = "pin-project"
|
||||||
version = "1.1.12"
|
version = "1.1.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9"
|
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pin-project-internal",
|
"pin-project-internal",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-internal"
|
name = "pin-project-internal"
|
||||||
version = "1.1.12"
|
version = "1.1.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389"
|
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -4742,7 +4743,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"quick-xml",
|
"quick-xml 0.39.4",
|
||||||
"serde",
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
@@ -4798,6 +4799,15 @@ dependencies = [
|
|||||||
"universal-hash",
|
"universal-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "polycool"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polyval"
|
name = "polyval"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -5014,6 +5024,15 @@ name = "quick-xml"
|
|||||||
version = "0.39.4"
|
version = "0.39.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
|
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 = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -5489,9 +5508,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rsqlite-vfs"
|
name = "rsqlite-vfs"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d"
|
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -5564,7 +5583,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5854,9 +5873,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.149"
|
version = "1.0.150"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -6233,7 +6252,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6305,9 +6324,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlite-wasm-rs"
|
name = "sqlite-wasm-rs"
|
||||||
version = "0.5.3"
|
version = "0.5.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36"
|
checksum = "cdd578e94101503d97e2b286bbf8db2135035ca24b2ce4cbf3f9e2fb2bbf1eee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -6449,9 +6468,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sysinfo"
|
name = "sysinfo"
|
||||||
version = "0.39.1"
|
version = "0.39.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a4deba334e1190ba7cb498327affa11e5ece10d26a30ab2f27fcf09504b8d8b6"
|
checksum = "14311e7e9a03114cd4b65eedd54e8fed2945e17f08586ae97ef53bc0669f9581"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -6498,9 +6517,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tao"
|
name = "tao"
|
||||||
version = "0.35.2"
|
version = "0.35.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4"
|
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"block2",
|
"block2",
|
||||||
@@ -6555,9 +6574,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tar"
|
name = "tar"
|
||||||
version = "0.4.45"
|
version = "0.4.46"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"filetime",
|
"filetime",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -6572,9 +6591,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri"
|
name = "tauri"
|
||||||
version = "2.11.1"
|
version = "2.11.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405"
|
checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -6623,9 +6642,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-build"
|
name = "tauri-build"
|
||||||
version = "2.6.1"
|
version = "2.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007"
|
checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"cargo_toml",
|
"cargo_toml",
|
||||||
@@ -6644,9 +6663,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-codegen"
|
name = "tauri-codegen"
|
||||||
version = "2.6.1"
|
version = "2.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528"
|
checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"brotli",
|
"brotli",
|
||||||
@@ -6671,9 +6690,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-macros"
|
name = "tauri-macros"
|
||||||
version = "2.6.1"
|
version = "2.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502"
|
checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -6685,9 +6704,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin"
|
name = "tauri-plugin"
|
||||||
version = "2.6.1"
|
version = "2.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee"
|
checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"glob",
|
"glob",
|
||||||
@@ -6874,9 +6893,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.11.1"
|
version = "2.11.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc"
|
checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cookie",
|
"cookie",
|
||||||
"dpi",
|
"dpi",
|
||||||
@@ -6899,9 +6918,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime-wry"
|
name = "tauri-runtime-wry"
|
||||||
version = "2.11.1"
|
version = "2.11.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0"
|
checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
@@ -6925,9 +6944,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-utils"
|
name = "tauri-utils"
|
||||||
version = "2.9.1"
|
version = "2.9.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec"
|
checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"brotli",
|
"brotli",
|
||||||
@@ -6954,7 +6973,7 @@ dependencies = [
|
|||||||
"serde_with",
|
"serde_with",
|
||||||
"swift-rs",
|
"swift-rs",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"toml 1.1.2+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
"urlpattern",
|
"urlpattern",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -6982,7 +7001,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7384,9 +7403,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-http"
|
name = "tower-http"
|
||||||
version = "0.6.10"
|
version = "0.6.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -7484,7 +7503,7 @@ dependencies = [
|
|||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7505,7 +7524,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7577,7 +7596,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"memoffset",
|
"memoffset",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8092,7 +8111,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
|
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quick-xml",
|
"quick-xml 0.39.4",
|
||||||
"quote",
|
"quote",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -8235,7 +8254,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8761,7 +8780,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9145,9 +9164,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerofrom"
|
name = "zerofrom"
|
||||||
version = "0.1.7"
|
version = "0.1.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
|
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerofrom-derive",
|
"zerofrom-derive",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "donutbrowser"
|
name = "donutbrowser"
|
||||||
version = "0.24.2"
|
version = "0.24.3"
|
||||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||||
authors = ["zhom@github"]
|
authors = ["zhom@github"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -100,12 +100,12 @@ playwright = { git = "https://github.com/zhom/playwright-rust", branch = "master
|
|||||||
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
|
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
|
||||||
rusqlite = { version = "0.39", features = ["bundled"] }
|
rusqlite = { version = "0.39", features = ["bundled"] }
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
toml = "0.9"
|
toml = "1.1"
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
regex-lite = "0.1"
|
regex-lite = "0.1"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
maxminddb = "0.28"
|
maxminddb = "0.28"
|
||||||
quick-xml = { version = "0.39", features = ["serialize"] }
|
quick-xml = { version = "0.40", features = ["serialize"] }
|
||||||
|
|
||||||
# VPN support
|
# VPN support
|
||||||
boringtun = "0.7"
|
boringtun = "0.7"
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ pub struct UpdateProfileRequest {
|
|||||||
pub tags: Option<Vec<String>>,
|
pub tags: Option<Vec<String>>,
|
||||||
pub extension_group_id: Option<String>,
|
pub extension_group_id: Option<String>,
|
||||||
pub proxy_bypass_rules: Option<Vec<String>>,
|
pub proxy_bypass_rules: Option<Vec<String>>,
|
||||||
|
/// One of "Disabled", "Regular", "Encrypted".
|
||||||
|
pub sync_mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -215,6 +217,20 @@ struct OpenUrlRequest {
|
|||||||
url: String,
|
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)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(
|
paths(
|
||||||
@@ -226,6 +242,7 @@ struct OpenUrlRequest {
|
|||||||
run_profile,
|
run_profile,
|
||||||
open_url_in_profile,
|
open_url_in_profile,
|
||||||
kill_profile,
|
kill_profile,
|
||||||
|
import_profile_cookies,
|
||||||
get_groups,
|
get_groups,
|
||||||
get_group,
|
get_group,
|
||||||
create_group,
|
create_group,
|
||||||
@@ -268,6 +285,8 @@ struct OpenUrlRequest {
|
|||||||
RunProfileResponse,
|
RunProfileResponse,
|
||||||
RunProfileRequest,
|
RunProfileRequest,
|
||||||
OpenUrlRequest,
|
OpenUrlRequest,
|
||||||
|
ImportCookiesRequest,
|
||||||
|
ImportCookiesResponse,
|
||||||
ProxySettings,
|
ProxySettings,
|
||||||
)),
|
)),
|
||||||
tags(
|
tags(
|
||||||
@@ -277,6 +296,7 @@ struct OpenUrlRequest {
|
|||||||
(name = "proxies", description = "Proxy management endpoints"),
|
(name = "proxies", description = "Proxy management endpoints"),
|
||||||
(name = "vpns", description = "VPN management endpoints"),
|
(name = "vpns", description = "VPN management endpoints"),
|
||||||
(name = "browsers", description = "Browser management endpoints"),
|
(name = "browsers", description = "Browser management endpoints"),
|
||||||
|
(name = "cookies", description = "Cookie management endpoints"),
|
||||||
),
|
),
|
||||||
modifiers(&SecurityAddon),
|
modifiers(&SecurityAddon),
|
||||||
)]
|
)]
|
||||||
@@ -363,6 +383,7 @@ impl ApiServer {
|
|||||||
.routes(routes!(run_profile))
|
.routes(routes!(run_profile))
|
||||||
.routes(routes!(open_url_in_profile))
|
.routes(routes!(open_url_in_profile))
|
||||||
.routes(routes!(kill_profile))
|
.routes(routes!(kill_profile))
|
||||||
|
.routes(routes!(import_profile_cookies))
|
||||||
.routes(routes!(get_groups, create_group))
|
.routes(routes!(get_groups, create_group))
|
||||||
.routes(routes!(get_group, update_group, delete_group))
|
.routes(routes!(get_group, update_group, delete_group))
|
||||||
.routes(routes!(get_tags))
|
.routes(routes!(get_tags))
|
||||||
@@ -397,10 +418,15 @@ impl ApiServer {
|
|||||||
.route("/events", get(ws_handler))
|
.route("/events", get(ws_handler))
|
||||||
.with_state(ws_state);
|
.with_state(ws_state);
|
||||||
|
|
||||||
|
let api_for_v1 = api.clone();
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.merge(v1_routes)
|
.merge(v1_routes)
|
||||||
.nest("/ws", ws_routes)
|
.nest("/ws", ws_routes)
|
||||||
.route("/openapi.json", get(move || async move { Json(api) }))
|
.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
|
// Outermost layer: logs every request so customer reports show what
|
||||||
// their automation is actually calling, what the response status was,
|
// their automation is actually calling, what the response status was,
|
||||||
// and how long it took. Never logs request bodies or auth headers.
|
// 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
|
// Return updated profile
|
||||||
get_profile(Path(id), State(state)).await
|
get_profile(Path(id), State(state)).await
|
||||||
}
|
}
|
||||||
@@ -1818,6 +1853,77 @@ async fn kill_profile(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
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
|
// API Handler - Download Browser
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
|
|||||||
@@ -376,11 +376,12 @@ impl CamoufoxConfigBuilder {
|
|||||||
(config, target_os)
|
(config, target_os)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add random window history length
|
// Note: we used to spoof `window.history.length` to a random value in
|
||||||
config.insert(
|
// [1, 5] here. Newer Camoufox builds clamp the docShell session history
|
||||||
"window.history.length".to_string(),
|
// to this value, which disables the toolbar back/forward buttons when
|
||||||
serde_json::json!(rng.random_range(1..=5)),
|
// 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
|
// Add fonts
|
||||||
if !self.custom_fonts_only {
|
if !self.custom_fonts_only {
|
||||||
|
|||||||
@@ -222,10 +222,16 @@ impl CamoufoxManager {
|
|||||||
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?;
|
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?;
|
||||||
|
|
||||||
// Parse the fingerprint config JSON
|
// 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)
|
serde_json::from_str(&custom_config)
|
||||||
.map_err(|e| format!("Failed to parse fingerprint config: {e}"))?;
|
.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
|
// Convert to environment variables using CAMOU_CONFIG chunking
|
||||||
let env_vars = crate::camoufox::env_vars::config_to_env_vars(&fingerprint_config)
|
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}"))?;
|
.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()
|
.spawn()
|
||||||
.map_err(|e| format!("Failed to spawn Camoufox process: {e}"))?;
|
.map_err(|e| format!("Failed to spawn Camoufox process: {e}"))?;
|
||||||
|
|
||||||
@@ -296,6 +302,34 @@ impl CamoufoxManager {
|
|||||||
|
|
||||||
log::info!("Camoufox launched with PID: {:?}", process_id);
|
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
|
// Store the instance
|
||||||
let instance = CamoufoxInstance {
|
let instance = CamoufoxInstance {
|
||||||
id: instance_id.clone(),
|
id: instance_id.clone(),
|
||||||
@@ -557,28 +591,28 @@ impl CamoufoxManager {
|
|||||||
|
|
||||||
for (id, instance) in inner.instances.iter() {
|
for (id, instance) in inner.instances.iter() {
|
||||||
if let Some(process_id) = instance.process_id {
|
if let Some(process_id) = instance.process_id {
|
||||||
// Check if the process is still alive
|
|
||||||
if !self.is_server_running(process_id).await {
|
if !self.is_server_running(process_id).await {
|
||||||
// Process is dead
|
log::info!(
|
||||||
// Camoufox instance is no longer running
|
"Camoufox instance {} (PID {}) is no longer running; profile_path={:?}",
|
||||||
|
id,
|
||||||
|
process_id,
|
||||||
|
instance.profile_path
|
||||||
|
);
|
||||||
dead_instances.push(id.clone());
|
dead_instances.push(id.clone());
|
||||||
instances_to_remove.push(id.clone());
|
instances_to_remove.push(id.clone());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No process_id means it's likely a dead instance
|
log::info!("Camoufox instance {} has no PID, marking as dead", id);
|
||||||
// Camoufox instance has no PID, marking as dead
|
|
||||||
dead_instances.push(id.clone());
|
dead_instances.push(id.clone());
|
||||||
instances_to_remove.push(id.clone());
|
instances_to_remove.push(id.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove dead instances
|
|
||||||
if !instances_to_remove.is_empty() {
|
if !instances_to_remove.is_empty() {
|
||||||
let mut inner = self.inner.lock().await;
|
let mut inner = self.inner.lock().await;
|
||||||
for id in &instances_to_remove {
|
for id in &instances_to_remove {
|
||||||
inner.instances.remove(id);
|
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
|
// Patch user.js with Camoufox-specific overrides on every launch. This
|
||||||
// donut-proxy and never falls back to stale proxy settings baked into prefs.js
|
// always runs (not gated on the proxy being set) because Camoufox's
|
||||||
// from a previous session. user.js values override prefs.js on every launch.
|
// bundled camoufox.cfg ships defaults that break basic browser features
|
||||||
if let Some(proxy_str) = &config.proxy {
|
// and we need to override them per-profile.
|
||||||
|
{
|
||||||
let user_js_path = profile_path.join("user.js");
|
let user_js_path = profile_path.join("user.js");
|
||||||
let mut prefs = String::new();
|
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) {
|
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() {
|
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_str(line);
|
||||||
prefs.push('\n');
|
prefs.push('\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(parsed) = url::Url::parse(proxy_str) {
|
// Camoufox's bundled camoufox.cfg sets these to 0, which makes
|
||||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
// docShell remember zero prior pages and leaves the toolbar
|
||||||
let port = parsed.port().unwrap_or(8080);
|
// back/forward buttons permanently disabled no matter how much
|
||||||
let scheme = parsed.scheme();
|
// 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" {
|
// Required for sideloaded extensions:
|
||||||
prefs.push_str(&format!(
|
// - signatures.required=false accepts unsigned .xpi (Camoufox is built
|
||||||
"user_pref(\"network.proxy.type\", 1);\n\
|
// without MOZ_REQUIRE_SIGNING so this is honored).
|
||||||
user_pref(\"network.proxy.socks\", \"{host}\");\n\
|
// - startupScanScopes=1 rescans SCOPE_PROFILE on each launch so newly
|
||||||
user_pref(\"network.proxy.socks_port\", {port});\n\
|
// dropped .xpi files in <profile>/extensions/ get registered.
|
||||||
user_pref(\"network.proxy.socks_version\", {});\n\
|
prefs.push_str(
|
||||||
user_pref(\"network.proxy.socks_remote_dns\", true);\n",
|
"user_pref(\"xpinstall.signatures.required\", false);\n\
|
||||||
if scheme == "socks5" { 5 } else { 4 }
|
user_pref(\"extensions.startupScanScopes\", 1);\n",
|
||||||
));
|
);
|
||||||
} 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) {
|
if let Some(proxy_str) = &config.proxy {
|
||||||
log::warn!("Failed to write proxy prefs to user.js: {e}");
|
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
|
self
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ pub struct Extension {
|
|||||||
pub author: Option<String>,
|
pub author: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub homepage_url: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -157,6 +162,32 @@ fn extract_manifest_metadata(
|
|||||||
(name, version, description, author, homepage_url)
|
(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)> {
|
fn extract_icon_from_archive(file_data: &[u8], file_type: &str) -> Option<(Vec<u8>, String)> {
|
||||||
let zip_start = if file_type == "crx" {
|
let zip_start = if file_type == "crx" {
|
||||||
find_zip_start(file_data)
|
find_zip_start(file_data)
|
||||||
@@ -285,6 +316,7 @@ impl ExtensionManager {
|
|||||||
name
|
name
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let gecko_id = extract_gecko_id(&file_data, &file_type);
|
||||||
let ext = Extension {
|
let ext = Extension {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
name: final_name,
|
name: final_name,
|
||||||
@@ -299,6 +331,7 @@ impl ExtensionManager {
|
|||||||
description,
|
description,
|
||||||
author,
|
author,
|
||||||
homepage_url,
|
homepage_url,
|
||||||
|
gecko_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
let file_dir = self.get_file_dir(&ext.id);
|
let file_dir = self.get_file_dir(&ext.id);
|
||||||
@@ -415,6 +448,7 @@ impl ExtensionManager {
|
|||||||
ext.name = mn;
|
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) {
|
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}"));
|
let icon_path = self.get_extension_dir(id).join(format!("icon.{icon_ext}"));
|
||||||
@@ -893,24 +927,33 @@ impl ExtensionManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let src_file = self.get_file_dir(ext_id).join(&ext.file_name);
|
let src_file = self.get_file_dir(ext_id).join(&ext.file_name);
|
||||||
if src_file.exists() {
|
if !src_file.exists() {
|
||||||
// Firefox expects .xpi files in extensions dir
|
continue;
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
let file_path = file_dir.join(&ext.file_name);
|
||||||
if let Ok(file_data) = fs::read(&file_path) {
|
if let Ok(file_data) = fs::read(&file_path) {
|
||||||
let (manifest_name, version, description, author, homepage_url) =
|
let mut updated_ext = ext.clone();
|
||||||
extract_manifest_metadata(&file_data, &ext.file_type);
|
let mut changed = false;
|
||||||
if version.is_some()
|
|
||||||
|| description.is_some()
|
if needs_meta_backfill {
|
||||||
|| author.is_some()
|
let (manifest_name, version, description, author, homepage_url) =
|
||||||
|| homepage_url.is_some()
|
extract_manifest_metadata(&file_data, &ext.file_type);
|
||||||
|| manifest_name.is_some()
|
if version.is_some()
|
||||||
{
|
|| description.is_some()
|
||||||
let mut updated_ext = ext.clone();
|
|| author.is_some()
|
||||||
if let Some(v) = version {
|
|| homepage_url.is_some()
|
||||||
updated_ext.version = Some(v);
|
|| 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 needs_gecko_backfill {
|
||||||
if let Some(a) = author {
|
if let Some(gid) = extract_gecko_id(&file_data, &ext.file_type) {
|
||||||
updated_ext.author = Some(a);
|
updated_ext.gecko_id = Some(gid);
|
||||||
}
|
changed = true;
|
||||||
if let Some(h) = homepage_url {
|
|
||||||
updated_ext.homepage_url = Some(h);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
let metadata_path = self.get_metadata_path(&ext.id);
|
let metadata_path = self.get_metadata_path(&ext.id);
|
||||||
if let Ok(json) = serde_json::to_string_pretty(&updated_ext) {
|
if let Ok(json) = serde_json::to_string_pretty(&updated_ext) {
|
||||||
let _ = fs::write(metadata_path, json);
|
let _ = fs::write(metadata_path, json);
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ use settings_manager::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use sync::{
|
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,
|
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,
|
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,
|
set_e2e_password, set_extension_group_sync_enabled, set_extension_sync_enabled,
|
||||||
@@ -2057,6 +2057,7 @@ pub fn run() {
|
|||||||
get_sync_settings,
|
get_sync_settings,
|
||||||
save_sync_settings,
|
save_sync_settings,
|
||||||
set_profile_sync_mode,
|
set_profile_sync_mode,
|
||||||
|
cancel_profile_sync,
|
||||||
request_profile_sync,
|
request_profile_sync,
|
||||||
set_proxy_sync_enabled,
|
set_proxy_sync_enabled,
|
||||||
set_group_sync_enabled,
|
set_group_sync_enabled,
|
||||||
|
|||||||
+500
-1
@@ -33,6 +33,48 @@ pub struct McpTool {
|
|||||||
pub input_schema: serde_json::Value,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct McpRequest {
|
pub struct McpRequest {
|
||||||
@@ -1103,6 +1145,25 @@ impl McpServer {
|
|||||||
"required": ["profile_id"]
|
"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
|
// Team lock tools
|
||||||
McpTool {
|
McpTool {
|
||||||
name: "get_team_locks".to_string(),
|
name: "get_team_locks".to_string(),
|
||||||
@@ -1354,6 +1415,76 @@ impl McpServer {
|
|||||||
"required": ["profile_id"]
|
"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)
|
.handle_assign_extension_group_to_profile(arguments)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
// Cookie management
|
||||||
|
"import_profile_cookies" => self.handle_import_profile_cookies(arguments).await,
|
||||||
// Team lock tools
|
// Team lock tools
|
||||||
"get_team_locks" => self.handle_get_team_locks().await,
|
"get_team_locks" => self.handle_get_team_locks().await,
|
||||||
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).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::require_paid_subscription("Browser automation").await?;
|
||||||
self.handle_get_page_info(arguments).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 {
|
_ => Err(McpError {
|
||||||
code: -32602,
|
code: -32602,
|
||||||
message: format!("Unknown tool: {tool_name}"),
|
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
|
// VPN management handlers
|
||||||
async fn handle_import_vpn(
|
async fn handle_import_vpn(
|
||||||
&self,
|
&self,
|
||||||
@@ -4263,6 +4476,11 @@ impl McpServer {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("text");
|
.unwrap_or("text");
|
||||||
let selector = arguments.get("selector").and_then(|v| v.as_str());
|
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 profile = self.get_running_profile(profile_id)?;
|
||||||
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
|
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
|
||||||
@@ -4310,10 +4528,28 @@ impl McpServer {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("");
|
.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!({
|
Ok(serde_json::json!({
|
||||||
"content": [{
|
"content": [{
|
||||||
"type": "text",
|
"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 ---
|
// --- Synchronizer handlers ---
|
||||||
|
|
||||||
async fn handle_start_sync_session(
|
async fn handle_start_sync_session(
|
||||||
@@ -4560,6 +5057,8 @@ mod tests {
|
|||||||
assert!(tool_names.contains(&"delete_extension"));
|
assert!(tool_names.contains(&"delete_extension"));
|
||||||
assert!(tool_names.contains(&"delete_extension_group"));
|
assert!(tool_names.contains(&"delete_extension_group"));
|
||||||
assert!(tool_names.contains(&"assign_extension_group_to_profile"));
|
assert!(tool_names.contains(&"assign_extension_group_to_profile"));
|
||||||
|
// Cookie tools
|
||||||
|
assert!(tool_names.contains(&"import_profile_cookies"));
|
||||||
// Team lock tools
|
// Team lock tools
|
||||||
assert!(tool_names.contains(&"get_team_locks"));
|
assert!(tool_names.contains(&"get_team_locks"));
|
||||||
assert!(tool_names.contains(&"get_team_lock_status"));
|
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\", \"\");".to_string(),
|
||||||
"user_pref(\"startup.homepage_welcome_url.additional\", \"\");".to_string(),
|
"user_pref(\"startup.homepage_welcome_url.additional\", \"\");".to_string(),
|
||||||
"user_pref(\"startup.homepage_override_url\", \"\");".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.enabled\", true);".to_string(),
|
||||||
"user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(),
|
"user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(),
|
||||||
"user_pref(\"extensions.autoDisableScopes\", 0);".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
|
// Completely disable browser update checking
|
||||||
"user_pref(\"app.update.enabled\", false);".to_string(),
|
"user_pref(\"app.update.enabled\", false);".to_string(),
|
||||||
"user_pref(\"app.update.auto\", false);".to_string(),
|
"user_pref(\"app.update.auto\", false);".to_string(),
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ pub struct AppSettings {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt
|
pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt
|
||||||
#[serde(default)]
|
#[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)]
|
#[serde(default)]
|
||||||
pub window_resize_warning_dismissed: bool,
|
pub window_resize_warning_dismissed: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
+241
-169
@@ -10,11 +10,48 @@ use chrono::{DateTime, Utc};
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex as StdMutex};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tokio::sync::{Mutex as TokioMutex, Semaphore};
|
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
|
/// Upload/download concurrency limit
|
||||||
const SYNC_CONCURRENCY: usize = 32;
|
const SYNC_CONCURRENCY: usize = 32;
|
||||||
|
|
||||||
@@ -391,6 +428,9 @@ impl SyncEngine {
|
|||||||
let profile_dir = profiles_dir.join(profile.id.to_string());
|
let profile_dir = profiles_dir.join(profile.id.to_string());
|
||||||
let profile_id = 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
|
// Determine team key prefix for team profiles
|
||||||
let key_prefix = Self::get_team_key_prefix(profile).await;
|
let key_prefix = Self::get_team_key_prefix(profile).await;
|
||||||
|
|
||||||
@@ -514,10 +554,16 @@ impl SyncEngine {
|
|||||||
&diff.files_to_upload,
|
&diff.files_to_upload,
|
||||||
encryption_key.as_ref(),
|
encryption_key.as_ref(),
|
||||||
&key_prefix,
|
&key_prefix,
|
||||||
|
&cancel_flag,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cancel_flag.load(Ordering::Relaxed) {
|
||||||
|
log::info!("Sync cancelled for profile {} after uploads", profile_id);
|
||||||
|
return Err(SyncError::Cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
// Perform downloads
|
// Perform downloads
|
||||||
if !diff.files_to_download.is_empty() {
|
if !diff.files_to_download.is_empty() {
|
||||||
self
|
self
|
||||||
@@ -529,10 +575,16 @@ impl SyncEngine {
|
|||||||
&diff.files_to_download,
|
&diff.files_to_download,
|
||||||
encryption_key.as_ref(),
|
encryption_key.as_ref(),
|
||||||
&key_prefix,
|
&key_prefix,
|
||||||
|
&cancel_flag,
|
||||||
)
|
)
|
||||||
.await?;
|
.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)
|
// Delete local files that don't exist remotely (when remote is newer)
|
||||||
for path in &diff.files_to_delete_local {
|
for path in &diff.files_to_delete_local {
|
||||||
let file_path = profile_dir.join(path);
|
let file_path = profile_dir.join(path);
|
||||||
@@ -823,6 +875,7 @@ impl SyncEngine {
|
|||||||
files: &[super::manifest::ManifestFileEntry],
|
files: &[super::manifest::ManifestFileEntry],
|
||||||
encryption_key: Option<&[u8; 32]>,
|
encryption_key: Option<&[u8; 32]>,
|
||||||
key_prefix: &str,
|
key_prefix: &str,
|
||||||
|
cancel_flag: &Arc<AtomicBool>,
|
||||||
) -> SyncResult<()> {
|
) -> SyncResult<()> {
|
||||||
if files.is_empty() {
|
if files.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -930,6 +983,13 @@ impl SyncEngine {
|
|||||||
let save_counter = Arc::new(AtomicU64::new(0));
|
let save_counter = Arc::new(AtomicU64::new(0));
|
||||||
|
|
||||||
for file in &files_to_process {
|
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 sem = semaphore.clone();
|
||||||
let file_path = profile_dir.join(&file.path);
|
let file_path = profile_dir.join(&file.path);
|
||||||
let relative_path = file.path.clone();
|
let relative_path = file.path.clone();
|
||||||
@@ -958,6 +1018,7 @@ impl SyncEngine {
|
|||||||
let resume_state = resume_state.clone();
|
let resume_state = resume_state.clone();
|
||||||
let save_counter = save_counter.clone();
|
let save_counter = save_counter.clone();
|
||||||
let profile_dir_clone = profile_dir.clone();
|
let profile_dir_clone = profile_dir.clone();
|
||||||
|
let cancel_flag_task = cancel_flag.clone();
|
||||||
let content_type = mime_guess::from_path(&file.path)
|
let content_type = mime_guess::from_path(&file.path)
|
||||||
.first()
|
.first()
|
||||||
.map(|m| m.to_string());
|
.map(|m| m.to_string());
|
||||||
@@ -965,6 +1026,10 @@ impl SyncEngine {
|
|||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move {
|
||||||
let _permit = sem.acquire().await.unwrap();
|
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) {
|
let data = match fs::read(&file_path) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !critical => {
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !critical => {
|
||||||
@@ -1095,6 +1160,7 @@ impl SyncEngine {
|
|||||||
files: &[super::manifest::ManifestFileEntry],
|
files: &[super::manifest::ManifestFileEntry],
|
||||||
encryption_key: Option<&[u8; 32]>,
|
encryption_key: Option<&[u8; 32]>,
|
||||||
key_prefix: &str,
|
key_prefix: &str,
|
||||||
|
cancel_flag: &Arc<AtomicBool>,
|
||||||
) -> SyncResult<()> {
|
) -> SyncResult<()> {
|
||||||
if files.is_empty() {
|
if files.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -1194,6 +1260,13 @@ impl SyncEngine {
|
|||||||
let save_counter = Arc::new(AtomicU64::new(0));
|
let save_counter = Arc::new(AtomicU64::new(0));
|
||||||
|
|
||||||
for file in &files_to_process {
|
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 sem = semaphore.clone();
|
||||||
let file_path = profile_dir.join(&file.path);
|
let file_path = profile_dir.join(&file.path);
|
||||||
let relative_path = file.path.clone();
|
let relative_path = file.path.clone();
|
||||||
@@ -1222,13 +1295,21 @@ impl SyncEngine {
|
|||||||
let resume_state = resume_state.clone();
|
let resume_state = resume_state.clone();
|
||||||
let save_counter = save_counter.clone();
|
let save_counter = save_counter.clone();
|
||||||
let profile_dir_clone = profile_dir.clone();
|
let profile_dir_clone = profile_dir.clone();
|
||||||
|
let cancel_flag_task = cancel_flag.clone();
|
||||||
|
|
||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move {
|
||||||
let _permit = sem.acquire().await.unwrap();
|
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
|
// Retry loop for network downloads
|
||||||
let mut last_err = String::new();
|
let mut last_err = String::new();
|
||||||
for attempt in 0..MAX_FILE_RETRIES {
|
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 {
|
match client.download_bytes(&url).await {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
let write_data = if let Some(ref key) = enc_key {
|
let write_data = if let Some(ref key) = enc_key {
|
||||||
@@ -2361,6 +2442,8 @@ impl SyncEngine {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if !manifest.files.is_empty() {
|
if !manifest.files.is_empty() {
|
||||||
|
let cancel_flag = register_sync_cancel(profile_id);
|
||||||
|
let _cancel_guard = SyncCancelGuard(profile_id.to_string());
|
||||||
self
|
self
|
||||||
.download_profile_files(
|
.download_profile_files(
|
||||||
app_handle,
|
app_handle,
|
||||||
@@ -2370,6 +2453,7 @@ impl SyncEngine {
|
|||||||
&manifest.files,
|
&manifest.files,
|
||||||
encryption_key.as_ref(),
|
encryption_key.as_ref(),
|
||||||
key_prefix,
|
key_prefix,
|
||||||
|
&cancel_flag,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
@@ -2506,8 +2590,46 @@ impl SyncEngine {
|
|||||||
profiles_to_check.len()
|
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 {
|
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
|
match self
|
||||||
.download_profile_if_missing(app_handle, profile_id, key_prefix)
|
.download_profile_if_missing(app_handle, profile_id, key_prefix)
|
||||||
.await
|
.await
|
||||||
@@ -2571,6 +2693,24 @@ impl SyncEngine {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if has_personal_tombstone || has_team_tombstone {
|
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!(
|
log::info!(
|
||||||
"Profile {} has remote tombstone, deleting locally (deleted on another device)",
|
"Profile {} has remote tombstone, deleting locally (deleted on another device)",
|
||||||
pid
|
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());
|
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 {
|
if profile.ephemeral {
|
||||||
return Err("Cannot enable sync for an ephemeral profile".to_string());
|
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", ());
|
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 {
|
if enabling {
|
||||||
let is_running = profile.process_id.is_some();
|
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");
|
log::warn!("Scheduler not initialized, sync will not start");
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
if old_mode != SyncMode::Disabled {
|
||||||
let profile_id_clone = profile_id.clone();
|
match SyncEngine::create_from_settings(&app_handle).await {
|
||||||
let app_handle_clone = app_handle.clone();
|
Ok(engine) => {
|
||||||
tokio::spawn(async move {
|
if let Err(e) = engine.delete_profile(&profile_id).await {
|
||||||
match SyncEngine::create_from_settings(&app_handle_clone).await {
|
log::warn!("Failed to delete profile {} from sync: {}", profile_id, e);
|
||||||
Ok(engine) => {
|
} else {
|
||||||
if let Err(e) = engine.delete_profile(&profile_id_clone).await {
|
log::info!("Profile {} deleted from sync service", profile_id);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
Err(e) => {
|
||||||
|
log::debug!("Sync not configured, skipping remote deletion: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = events::emit(
|
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
|
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(
|
pub async fn trigger_sync_for_profile(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
profile_id: String,
|
profile_id: String,
|
||||||
@@ -3222,43 +3402,29 @@ pub async fn set_proxy_sync_enabled(
|
|||||||
let proxy = proxies
|
let proxy = proxies
|
||||||
.iter()
|
.iter()
|
||||||
.find(|p| p.id == proxy_id)
|
.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
|
// Block modifying sync for cloud-managed proxies
|
||||||
if proxy.is_cloud_managed {
|
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 disabling, check if proxy is used by any synced profile
|
||||||
if !enabled && is_proxy_used_by_synced_profile(&proxy_id) {
|
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 enabling, check that sync settings are configured
|
||||||
if enabled {
|
if enabled {
|
||||||
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
|
ensure_sync_configured(&app_handle).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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_last_sync = if enabled { proxy.last_sync } else { None };
|
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", ());
|
let _ = events::emit("stored-proxies-changed", ());
|
||||||
|
|
||||||
@@ -3299,36 +3465,18 @@ pub async fn set_group_sync_enabled(
|
|||||||
groups
|
groups
|
||||||
.iter()
|
.iter()
|
||||||
.find(|g| g.id == group_id)
|
.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()
|
.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
// If disabling, check if group is used by any synced profile
|
// If disabling, check if group is used by any synced profile
|
||||||
if !enabled && is_group_used_by_synced_profile(&group_id) {
|
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 enabling, check that sync settings are configured
|
||||||
if enabled {
|
if enabled {
|
||||||
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
|
ensure_sync_configured(&app_handle).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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut updated_group = group.clone();
|
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();
|
let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap();
|
||||||
if let Err(e) = group_manager.update_group_internal(&updated_group) {
|
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();
|
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
|
||||||
storage
|
storage
|
||||||
.load_config(&vpn_id)
|
.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 disabling, check if VPN is used by any synced profile
|
||||||
if !enabled && is_vpn_used_by_synced_profile(&vpn_id) {
|
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 enabling, check that sync settings are configured
|
||||||
if enabled {
|
if enabled {
|
||||||
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
|
ensure_sync_configured(&app_handle).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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let last_sync = if enabled { vpn.last_sync } else { None };
|
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();
|
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
|
||||||
storage
|
storage
|
||||||
.update_sync_fields(&vpn_id, enabled, last_sync)
|
.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", ());
|
let _ = events::emit("vpn-configs-changed", ());
|
||||||
@@ -3526,48 +3662,10 @@ pub fn get_unsynced_entity_counts() -> Result<UnsyncedEntityCounts, String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Result<(), String> {
|
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
|
// Intentionally excludes profiles: enabling profile sync uploads the entire
|
||||||
// groups/proxies/vpns syncing while their profiles stay local-only — the
|
// browser data dir per profile, which is destructive if the user expected
|
||||||
// long-standing source of issue #352. Encrypted mode wins when an E2E
|
// an opt-in. Profile sync stays under explicit per-profile control via
|
||||||
// password is already configured; otherwise we fall back to plain Regular.
|
// set_profile_sync_mode. This command only touches metadata-sized entities.
|
||||||
{
|
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable sync for all unsynced proxies
|
// 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();
|
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||||
manager
|
manager
|
||||||
.get_extension(&extension_id)
|
.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 {
|
if enabled {
|
||||||
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
|
ensure_sync_configured(&app_handle).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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut updated_ext = ext;
|
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();
|
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||||
manager
|
manager
|
||||||
.update_extension_internal(&updated_ext)
|
.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", ());
|
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();
|
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||||
manager
|
manager
|
||||||
.get_group(&extension_group_id)
|
.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 {
|
if enabled {
|
||||||
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
|
ensure_sync_configured(&app_handle).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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut updated_group = group;
|
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();
|
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||||
manager
|
manager.update_group_internal(&updated_group).map_err(|e| {
|
||||||
.update_group_internal(&updated_group)
|
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
|
||||||
.map_err(|e| format!("Failed to update extension group sync: {e}"))?;
|
.to_string()
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = events::emit("extensions-changed", ());
|
let _ = events::emit("extensions-changed", ());
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
|
|||||||
"**/startupCache/**",
|
"**/startupCache/**",
|
||||||
"**/safebrowsing/**",
|
"**/safebrowsing/**",
|
||||||
"**/storage/temporary/**",
|
"**/storage/temporary/**",
|
||||||
|
"**/storage/default/*/cache/**",
|
||||||
|
"**/datareporting/**",
|
||||||
|
"**/saved-telemetry-pings/**",
|
||||||
|
"**/sessionstore-backups/**",
|
||||||
|
"**/sessions/**",
|
||||||
|
"**/serviceworker.txt",
|
||||||
|
"**/AlternateServices.bin",
|
||||||
|
"**/SiteSecurityServiceState.bin",
|
||||||
|
"**/favicons.sqlite",
|
||||||
|
"**/favicons.sqlite-*",
|
||||||
"**/crashes/**",
|
"**/crashes/**",
|
||||||
"**/minidumps/**",
|
"**/minidumps/**",
|
||||||
"*.tmp",
|
"*.tmp",
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ pub use encryption::{
|
|||||||
check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password,
|
check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password,
|
||||||
};
|
};
|
||||||
pub use engine::{
|
pub use engine::{
|
||||||
enable_extension_group_sync_if_needed, enable_group_sync_if_needed, enable_proxy_sync_if_needed,
|
cancel_profile_sync, enable_extension_group_sync_if_needed, enable_group_sync_if_needed,
|
||||||
enable_sync_for_all_entities, enable_vpn_sync_if_needed, get_unsynced_entity_counts,
|
enable_proxy_sync_if_needed, enable_sync_for_all_entities, enable_vpn_sync_if_needed,
|
||||||
is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
|
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_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,
|
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,
|
rollover_encryption_for_all_entities, set_extension_group_sync_enabled,
|
||||||
|
|||||||
@@ -716,16 +716,18 @@ impl SyncScheduler {
|
|||||||
match entity_type.as_str() {
|
match entity_type.as_str() {
|
||||||
"profile" => {
|
"profile" => {
|
||||||
let profile_manager = ProfileManager::instance();
|
let profile_manager = ProfileManager::instance();
|
||||||
let has_profile = {
|
let local_sync_enabled = {
|
||||||
if let Ok(profiles) = profile_manager.list_profiles() {
|
if let Ok(profiles) = profile_manager.list_profiles() {
|
||||||
let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok();
|
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 {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if has_profile {
|
if local_sync_enabled {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Profile {} was deleted remotely, deleting locally",
|
"Profile {} was deleted remotely, deleting locally",
|
||||||
entity_id
|
entity_id
|
||||||
@@ -733,6 +735,11 @@ impl SyncScheduler {
|
|||||||
if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) {
|
if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) {
|
||||||
log::warn!("Failed to delete tombstoned profile {}: {}", entity_id, e);
|
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" => {
|
"proxy" => {
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ pub enum SyncError {
|
|||||||
SerializationError(String),
|
SerializationError(String),
|
||||||
ConflictError(String),
|
ConflictError(String),
|
||||||
InvalidData(String),
|
InvalidData(String),
|
||||||
|
Cancelled,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for SyncError {
|
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::SerializationError(msg) => write!(f, "Serialization error: {msg}"),
|
||||||
SyncError::ConflictError(msg) => write!(f, "Conflict error: {msg}"),
|
SyncError::ConflictError(msg) => write!(f, "Conflict error: {msg}"),
|
||||||
SyncError::InvalidData(msg) => write!(f, "Invalid data: {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",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Donut",
|
"productName": "Donut",
|
||||||
"version": "0.24.2",
|
"version": "0.24.3",
|
||||||
"identifier": "com.donutbrowser",
|
"identifier": "com.donutbrowser",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
"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 { AccountPage } from "@/components/account-page";
|
||||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||||
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
||||||
|
import { CommandPalette } from "@/components/command-palette";
|
||||||
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
||||||
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
||||||
import { CookieManagementDialog } from "@/components/cookie-management-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 { ProxyManagementDialog } from "@/components/proxy-management-dialog";
|
||||||
import { type AppPage, RailNav } from "@/components/rail-nav";
|
import { type AppPage, RailNav } from "@/components/rail-nav";
|
||||||
import { SettingsDialog } from "@/components/settings-dialog";
|
import { SettingsDialog } from "@/components/settings-dialog";
|
||||||
|
import { ShortcutsPage } from "@/components/shortcuts-page";
|
||||||
import { SyncAllDialog } from "@/components/sync-all-dialog";
|
import { SyncAllDialog } from "@/components/sync-all-dialog";
|
||||||
import { SyncConfigDialog } from "@/components/sync-config-dialog";
|
import { SyncConfigDialog } from "@/components/sync-config-dialog";
|
||||||
import { SyncFollowerDialog } from "@/components/sync-follower-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 { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||||
import { translateBackendError } from "@/lib/backend-errors";
|
import { translateBackendError } from "@/lib/backend-errors";
|
||||||
|
import {
|
||||||
|
matchesGroupDigit,
|
||||||
|
matchesShortcut,
|
||||||
|
SHORTCUTS,
|
||||||
|
type ShortcutId,
|
||||||
|
} from "@/lib/shortcuts";
|
||||||
import {
|
import {
|
||||||
dismissToast,
|
dismissToast,
|
||||||
showErrorToast,
|
showErrorToast,
|
||||||
@@ -149,6 +157,11 @@ export default function Home() {
|
|||||||
const [proxyManagementInitialTab, setProxyManagementInitialTab] = useState<
|
const [proxyManagementInitialTab, setProxyManagementInitialTab] = useState<
|
||||||
"proxies" | "vpns"
|
"proxies" | "vpns"
|
||||||
>("proxies");
|
>("proxies");
|
||||||
|
const [extensionManagementInitialTab, setExtensionManagementInitialTab] =
|
||||||
|
useState<"extensions" | "groups">("extensions");
|
||||||
|
const [integrationsInitialTab, setIntegrationsInitialTab] = useState<
|
||||||
|
"api" | "mcp"
|
||||||
|
>("api");
|
||||||
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
||||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||||
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
|
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
|
||||||
@@ -221,6 +234,11 @@ export default function Home() {
|
|||||||
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
|
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
|
||||||
const [currentProfileForSync, setCurrentProfileForSync] =
|
const [currentProfileForSync, setCurrentProfileForSync] =
|
||||||
useState<BrowserProfile | null>(null);
|
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 } =
|
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
||||||
usePermissions();
|
usePermissions();
|
||||||
|
|
||||||
@@ -273,9 +291,134 @@ export default function Home() {
|
|||||||
case "account":
|
case "account":
|
||||||
setAccountDialogOpen(true);
|
setAccountDialogOpen(true);
|
||||||
break;
|
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
|
// Check for missing binaries and offer to download them
|
||||||
const checkMissingBinaries = useCallback(async () => {
|
const checkMissingBinaries = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1031,7 +1174,7 @@ export default function Home() {
|
|||||||
failed_count: payload.failed_count ?? 0,
|
failed_count: payload.failed_count ?? 0,
|
||||||
phase: payload.phase,
|
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}
|
{isLoading && groupsData.length === 0 ? null : null}
|
||||||
<ProfilesDataTable
|
<ProfilesDataTable
|
||||||
profiles={filteredProfiles}
|
profiles={filteredProfiles}
|
||||||
|
infoDialogProfile={profileInfoDialog}
|
||||||
|
onInfoDialogProfileChange={setProfileInfoDialog}
|
||||||
onLaunchProfile={launchProfile}
|
onLaunchProfile={launchProfile}
|
||||||
onKillProfile={handleKillProfile}
|
onKillProfile={handleKillProfile}
|
||||||
onCloneProfile={handleCloneProfile}
|
onCloneProfile={handleCloneProfile}
|
||||||
@@ -1344,6 +1489,10 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{currentPage === "shortcuts" && (
|
||||||
|
<ShortcutsPage groupTargets={orderedGroupTargets} />
|
||||||
|
)}
|
||||||
|
|
||||||
{settingsDialogOpen && (
|
{settingsDialogOpen && (
|
||||||
<SettingsDialog
|
<SettingsDialog
|
||||||
isOpen={settingsDialogOpen}
|
isOpen={settingsDialogOpen}
|
||||||
@@ -1368,6 +1517,7 @@ export default function Home() {
|
|||||||
setCurrentPage("profiles");
|
setCurrentPage("profiles");
|
||||||
}}
|
}}
|
||||||
subPage={currentPage === "integrations"}
|
subPage={currentPage === "integrations"}
|
||||||
|
initialTab={integrationsInitialTab}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1404,6 +1554,7 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
limitedMode={false}
|
limitedMode={false}
|
||||||
subPage={currentPage === "extensions"}
|
subPage={currentPage === "extensions"}
|
||||||
|
initialTab={extensionManagementInitialTab}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1447,6 +1598,29 @@ export default function Home() {
|
|||||||
crossOsUnlocked={crossOsUnlocked}
|
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) => (
|
{pendingUrls.map((pendingUrl) => (
|
||||||
<ProfileSelectorDialog
|
<ProfileSelectorDialog
|
||||||
key={pendingUrl.id}
|
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 (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent>
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||||
import type { Extension, ExtensionGroup } from "@/types";
|
import type { Extension, ExtensionGroup } from "@/types";
|
||||||
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
|
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
|
||||||
@@ -130,6 +131,8 @@ interface ExtensionManagementDialogProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
limitedMode: boolean;
|
limitedMode: boolean;
|
||||||
subPage?: boolean;
|
subPage?: boolean;
|
||||||
|
/** Which tab is displayed when the dialog mounts; defaults to "extensions". */
|
||||||
|
initialTab?: "extensions" | "groups";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExtensionManagementDialog({
|
export function ExtensionManagementDialog({
|
||||||
@@ -137,6 +140,7 @@ export function ExtensionManagementDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
limitedMode,
|
limitedMode,
|
||||||
subPage,
|
subPage,
|
||||||
|
initialTab = "extensions",
|
||||||
}: ExtensionManagementDialogProps) {
|
}: ExtensionManagementDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||||
@@ -208,9 +212,10 @@ export function ExtensionManagementDialog({
|
|||||||
Record<string, boolean>
|
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">(
|
const [activeTab, setActiveTab] = useState<"extensions" | "groups">(
|
||||||
"extensions",
|
initialTab,
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
@@ -304,7 +309,11 @@ export function ExtensionManagementDialog({
|
|||||||
);
|
);
|
||||||
void loadData();
|
void loadData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
showErrorToast(
|
||||||
|
parseBackendError(err)
|
||||||
|
? translateBackendError(t, err)
|
||||||
|
: t("proxies.management.updateSyncFailed"),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: false }));
|
setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: false }));
|
||||||
}
|
}
|
||||||
@@ -327,7 +336,11 @@ export function ExtensionManagementDialog({
|
|||||||
);
|
);
|
||||||
void loadData();
|
void loadData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
showErrorToast(
|
||||||
|
parseBackendError(err)
|
||||||
|
? translateBackendError(t, err)
|
||||||
|
: t("proxies.management.updateSyncFailed"),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: false }));
|
setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: false }));
|
||||||
}
|
}
|
||||||
@@ -585,9 +598,15 @@ export function ExtensionManagementDialog({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const failed = results.filter((r) => r.status === "rejected").length;
|
const firstRejection = results.find((r) => r.status === "rejected") as
|
||||||
if (failed > 0) {
|
| PromiseRejectedResult
|
||||||
showErrorToast(t("proxies.management.updateSyncFailed"));
|
| undefined;
|
||||||
|
if (firstRejection) {
|
||||||
|
showErrorToast(
|
||||||
|
parseBackendError(firstRejection.reason)
|
||||||
|
? translateBackendError(t, firstRejection.reason)
|
||||||
|
: t("proxies.management.updateSyncFailed"),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
showSuccessToast(
|
showSuccessToast(
|
||||||
targetEnabled
|
targetEnabled
|
||||||
@@ -610,9 +629,15 @@ export function ExtensionManagementDialog({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const failed = results.filter((r) => r.status === "rejected").length;
|
const firstRejection = results.find((r) => r.status === "rejected") as
|
||||||
if (failed > 0) {
|
| PromiseRejectedResult
|
||||||
showErrorToast(t("proxies.management.updateSyncFailed"));
|
| undefined;
|
||||||
|
if (firstRejection) {
|
||||||
|
showErrorToast(
|
||||||
|
parseBackendError(firstRejection.reason)
|
||||||
|
? translateBackendError(t, firstRejection.reason)
|
||||||
|
: t("proxies.management.updateSyncFailed"),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
showSuccessToast(
|
showSuccessToast(
|
||||||
targetEnabled
|
targetEnabled
|
||||||
@@ -1120,6 +1145,7 @@ export function ExtensionManagementDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<AnimatedTabs
|
<AnimatedTabs
|
||||||
|
key={initialTab}
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(v) => setActiveTab(v as "extensions" | "groups")}
|
onValueChange={(v) => setActiveTab(v as "extensions" | "groups")}
|
||||||
className="flex-1 min-h-0 flex flex-col"
|
className="flex-1 min-h-0 flex flex-col"
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||||
import type { GroupWithCount, ProfileGroup } from "@/types";
|
import type { GroupWithCount, ProfileGroup } from "@/types";
|
||||||
import { RippleButton } from "./ui/ripple";
|
import { RippleButton } from "./ui/ripple";
|
||||||
@@ -262,8 +263,8 @@ export function GroupManagementDialog({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to toggle sync:", error);
|
console.error("Failed to toggle sync:", error);
|
||||||
showErrorToast(
|
showErrorToast(
|
||||||
error instanceof Error
|
parseBackendError(error)
|
||||||
? error.message
|
? translateBackendError(t, error)
|
||||||
: t("proxies.management.updateSyncFailed"),
|
: t("proxies.management.updateSyncFailed"),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -529,9 +530,15 @@ export function GroupManagementDialog({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const failed = results.filter((r) => r.status === "rejected").length;
|
const firstRejection = results.find((r) => r.status === "rejected") as
|
||||||
if (failed > 0) {
|
| PromiseRejectedResult
|
||||||
showErrorToast(t("proxies.management.updateSyncFailed"));
|
| undefined;
|
||||||
|
if (firstRejection) {
|
||||||
|
showErrorToast(
|
||||||
|
parseBackendError(firstRejection.reason)
|
||||||
|
? translateBackendError(t, firstRejection.reason)
|
||||||
|
: t("proxies.management.updateSyncFailed"),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
showSuccessToast(
|
showSuccessToast(
|
||||||
targetEnabled
|
targetEnabled
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ interface IntegrationsDialogProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
subPage?: boolean;
|
subPage?: boolean;
|
||||||
|
/** Which tab is displayed when the dialog mounts; defaults to "api". */
|
||||||
|
initialTab?: "api" | "mcp";
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentIcon({ category }: { category: AgentCategory }) {
|
function AgentIcon({ category }: { category: AgentCategory }) {
|
||||||
@@ -98,6 +100,7 @@ export function IntegrationsDialog({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
subPage,
|
subPage,
|
||||||
|
initialTab = "api",
|
||||||
}: IntegrationsDialogProps) {
|
}: IntegrationsDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [settings, setSettings] = useState<AppSettings>({
|
const [settings, setSettings] = useState<AppSettings>({
|
||||||
@@ -117,6 +120,7 @@ export function IntegrationsDialog({
|
|||||||
const [isMcpStarting, setIsMcpStarting] = useState(false);
|
const [isMcpStarting, setIsMcpStarting] = useState(false);
|
||||||
const [agents, setAgents] = useState<McpAgentInfo[]>([]);
|
const [agents, setAgents] = useState<McpAgentInfo[]>([]);
|
||||||
const [busyAgentIds, setBusyAgentIds] = useState<Set<string>>(new Set());
|
const [busyAgentIds, setBusyAgentIds] = useState<Set<string>>(new Set());
|
||||||
|
const [apiPortDraft, setApiPortDraft] = useState<string>("10108");
|
||||||
|
|
||||||
const { termsAccepted } = useWayfernTerms();
|
const { termsAccepted } = useWayfernTerms();
|
||||||
|
|
||||||
@@ -124,6 +128,7 @@ export function IntegrationsDialog({
|
|||||||
try {
|
try {
|
||||||
const loaded = await invoke<AppSettings>("get_app_settings");
|
const loaded = await invoke<AppSettings>("get_app_settings");
|
||||||
setSettings(loaded);
|
setSettings(loaded);
|
||||||
|
setApiPortDraft(String(loaded.api_port ?? ""));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load settings:", 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">
|
<div className="overflow-y-auto flex-1 min-h-0">
|
||||||
<AnimatedTabs defaultValue="api">
|
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
|
||||||
<AnimatedTabsList>
|
<AnimatedTabsList>
|
||||||
<AnimatedTabsTrigger value="api">
|
<AnimatedTabsTrigger value="api">
|
||||||
{t("integrations.tabApi")}
|
{t("integrations.tabApi")}
|
||||||
@@ -367,13 +372,24 @@ export function IntegrationsDialog({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={settings.api_port}
|
value={apiPortDraft}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
setApiPortDraft(e.target.value);
|
||||||
const val = Number.parseInt(e.target.value, 10);
|
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 });
|
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"
|
className="w-24 font-mono"
|
||||||
min={1}
|
min={1}
|
||||||
max={65535}
|
max={65535}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type Props = ButtonProps & {
|
|||||||
export const LoadingButton = ({ isLoading, className, ...props }: Props) => {
|
export const LoadingButton = ({ isLoading, className, ...props }: Props) => {
|
||||||
return (
|
return (
|
||||||
<UIButton
|
<UIButton
|
||||||
className={cn("grid place-items-center", className)}
|
className={cn("inline-flex items-center justify-center", className)}
|
||||||
{...props}
|
{...props}
|
||||||
disabled={props.disabled || isLoading}
|
disabled={props.disabled || isLoading}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -691,7 +691,7 @@ const TagsCell = React.memo<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-40 h-6 cursor-pointer">
|
<div className="w-full h-6 cursor-pointer">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>{ButtonContent}</TooltipTrigger>
|
<TooltipTrigger asChild>{ButtonContent}</TooltipTrigger>
|
||||||
{hiddenCount > 0 && (
|
{hiddenCount > 0 && (
|
||||||
@@ -717,7 +717,7 @@ const TagsCell = React.memo<{
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-40 h-6 relative",
|
"w-full h-6 relative",
|
||||||
isDisabled && "opacity-60 pointer-events-none",
|
isDisabled && "opacity-60 pointer-events-none",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -925,19 +925,17 @@ const NoteCell = React.memo<{
|
|||||||
}, [openNoteEditorFor, profile.id]);
|
}, [openNoteEditorFor, profile.id]);
|
||||||
|
|
||||||
const displayNote = effectiveNote ?? "";
|
const displayNote = effectiveNote ?? "";
|
||||||
const trimmedNote =
|
const showTooltip = displayNote.length > 0;
|
||||||
displayNote.length > 12 ? `${displayNote.slice(0, 12)}...` : displayNote;
|
|
||||||
const showTooltip = displayNote.length > 12 || displayNote.length > 0;
|
|
||||||
|
|
||||||
if (openNoteEditorFor !== profile.id) {
|
if (openNoteEditorFor !== profile.id) {
|
||||||
return (
|
return (
|
||||||
<div className="w-24 min-h-6">
|
<div className="w-full min-h-6">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
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
|
isDisabled
|
||||||
? "opacity-60 cursor-not-allowed"
|
? "opacity-60 cursor-not-allowed"
|
||||||
: "cursor-pointer hover:bg-accent/50",
|
: "cursor-pointer hover:bg-accent/50",
|
||||||
@@ -951,11 +949,11 @@ const NoteCell = React.memo<{
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm wrap-break-word",
|
"text-sm truncate block w-full",
|
||||||
!effectiveNote && "text-muted-foreground",
|
!effectiveNote && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{effectiveNote ? trimmedNote : t("profiles.note.empty")}
|
{effectiveNote ? displayNote : t("profiles.note.empty")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -974,7 +972,7 @@ const NoteCell = React.memo<{
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-24 relative",
|
"w-full relative",
|
||||||
isDisabled && "opacity-60 pointer-events-none",
|
isDisabled && "opacity-60 pointer-events-none",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -1052,6 +1050,13 @@ interface ProfilesDataTableProps {
|
|||||||
onSetPassword?: (profile: BrowserProfile) => void;
|
onSetPassword?: (profile: BrowserProfile) => void;
|
||||||
onChangePassword?: (profile: BrowserProfile) => void;
|
onChangePassword?: (profile: BrowserProfile) => void;
|
||||||
onRemovePassword?: (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({
|
export function ProfilesDataTable({
|
||||||
@@ -1084,6 +1089,8 @@ export function ProfilesDataTable({
|
|||||||
onSetPassword,
|
onSetPassword,
|
||||||
onChangePassword,
|
onChangePassword,
|
||||||
onRemovePassword,
|
onRemovePassword,
|
||||||
|
infoDialogProfile,
|
||||||
|
onInfoDialogProfileChange,
|
||||||
}: ProfilesDataTableProps) {
|
}: ProfilesDataTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
||||||
@@ -1155,8 +1162,22 @@ export function ProfilesDataTable({
|
|||||||
const [profileToDelete, setProfileToDelete] =
|
const [profileToDelete, setProfileToDelete] =
|
||||||
React.useState<BrowserProfile | null>(null);
|
React.useState<BrowserProfile | null>(null);
|
||||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||||
const [profileForInfoDialog, setProfileForInfoDialog] =
|
const [internalInfoDialogProfile, setInternalInfoDialogProfile] =
|
||||||
React.useState<BrowserProfile | null>(null);
|
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] =
|
const [bypassRulesProfile, setBypassRulesProfile] =
|
||||||
React.useState<BrowserProfile | null>(null);
|
React.useState<BrowserProfile | null>(null);
|
||||||
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
|
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
|
||||||
@@ -2836,7 +2857,7 @@ export function ProfilesDataTable({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t],
|
[t, setProfileForInfoDialog],
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
LuShield,
|
LuShield,
|
||||||
LuShieldCheck,
|
LuShieldCheck,
|
||||||
LuTrash2,
|
LuTrash2,
|
||||||
|
LuUpload,
|
||||||
LuUsers,
|
LuUsers,
|
||||||
LuX,
|
LuX,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
@@ -582,8 +583,9 @@ function ProfileInfoLayout({
|
|||||||
|
|
||||||
const deleteAction = findAction("delete");
|
const deleteAction = findAction("delete");
|
||||||
const fingerprintAction = findAction("fingerprint");
|
const fingerprintAction = findAction("fingerprint");
|
||||||
const cookiesAction =
|
const cookiesManageAction = findAction("manage cookies");
|
||||||
findAction("manage cookies") ?? findAction("copy cookies");
|
const cookiesCopyAction = findAction("copy cookies");
|
||||||
|
const cookiesAction = cookiesManageAction ?? cookiesCopyAction;
|
||||||
const extensionAction = findAction("extension");
|
const extensionAction = findAction("extension");
|
||||||
const syncAction = findAction("sync");
|
const syncAction = findAction("sync");
|
||||||
const _launchHookAction = findAction("hook") ?? findAction("launch hook");
|
const _launchHookAction = findAction("hook") ?? findAction("launch hook");
|
||||||
@@ -905,6 +907,8 @@ function ProfileInfoLayout({
|
|||||||
profile={profile}
|
profile={profile}
|
||||||
isRunning={isRunning}
|
isRunning={isRunning}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
|
onCopyCookies={cookiesCopyAction?.onClick}
|
||||||
|
onImportCookies={cookiesManageAction?.onClick}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1435,11 +1439,16 @@ function ExtensionsSectionInline({
|
|||||||
function CookiesSectionInline({
|
function CookiesSectionInline({
|
||||||
profile,
|
profile,
|
||||||
isRunning,
|
isRunning,
|
||||||
|
isDisabled,
|
||||||
|
onCopyCookies,
|
||||||
|
onImportCookies,
|
||||||
t,
|
t,
|
||||||
}: {
|
}: {
|
||||||
profile: BrowserProfile;
|
profile: BrowserProfile;
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
isDisabled: boolean;
|
isDisabled: boolean;
|
||||||
|
onCopyCookies?: () => void;
|
||||||
|
onImportCookies?: () => void;
|
||||||
t: (key: string, options?: Record<string, unknown>) => string;
|
t: (key: string, options?: Record<string, unknown>) => string;
|
||||||
}) {
|
}) {
|
||||||
type CookieStats = {
|
type CookieStats = {
|
||||||
@@ -1483,9 +1492,37 @@ function CookiesSectionInline({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 min-h-0 flex-1">
|
<div className="flex flex-col gap-3 min-h-0 flex-1">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<LuCookie className="size-4" />
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
{t("profileInfo.sections.cookies")}
|
<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>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("profileInfo.sectionDesc.cookies")}
|
{t("profileInfo.sectionDesc.cookies")}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||||
|
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types";
|
import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types";
|
||||||
@@ -394,8 +395,8 @@ export function ProxyManagementDialog({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to toggle sync:", error);
|
console.error("Failed to toggle sync:", error);
|
||||||
showErrorToast(
|
showErrorToast(
|
||||||
error instanceof Error
|
parseBackendError(error)
|
||||||
? error.message
|
? translateBackendError(t, error)
|
||||||
: t("proxies.management.updateSyncFailed"),
|
: t("proxies.management.updateSyncFailed"),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -458,8 +459,8 @@ export function ProxyManagementDialog({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to toggle VPN sync:", error);
|
console.error("Failed to toggle VPN sync:", error);
|
||||||
showErrorToast(
|
showErrorToast(
|
||||||
error instanceof Error
|
parseBackendError(error)
|
||||||
? error.message
|
? translateBackendError(t, error)
|
||||||
: t("proxies.management.updateSyncFailed"),
|
: t("proxies.management.updateSyncFailed"),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1010,9 +1011,15 @@ export function ProxyManagementDialog({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const failed = results.filter((r) => r.status === "rejected").length;
|
const firstRejection = results.find((r) => r.status === "rejected") as
|
||||||
if (failed > 0) {
|
| PromiseRejectedResult
|
||||||
showErrorToast(t("proxies.management.updateSyncFailed"));
|
| undefined;
|
||||||
|
if (firstRejection) {
|
||||||
|
showErrorToast(
|
||||||
|
parseBackendError(firstRejection.reason)
|
||||||
|
? translateBackendError(t, firstRejection.reason)
|
||||||
|
: t("proxies.management.updateSyncFailed"),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
showSuccessToast(
|
showSuccessToast(
|
||||||
targetEnabled
|
targetEnabled
|
||||||
@@ -1039,9 +1046,15 @@ export function ProxyManagementDialog({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const failed = results.filter((r) => r.status === "rejected").length;
|
const firstRejection = results.find((r) => r.status === "rejected") as
|
||||||
if (failed > 0) {
|
| PromiseRejectedResult
|
||||||
showErrorToast(t("vpns.management.updateSyncFailed"));
|
| undefined;
|
||||||
|
if (firstRejection) {
|
||||||
|
showErrorToast(
|
||||||
|
parseBackendError(firstRejection.reason)
|
||||||
|
? translateBackendError(t, firstRejection.reason)
|
||||||
|
: t("proxies.management.updateSyncFailed"),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
showSuccessToast(
|
showSuccessToast(
|
||||||
targetEnabled
|
targetEnabled
|
||||||
@@ -1055,7 +1068,7 @@ export function ProxyManagementDialog({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
<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 && (
|
{!subPage && (
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
|
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
|
||||||
@@ -1170,7 +1183,7 @@ export function ProxyManagementDialog({
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Table className="min-w-max">
|
<Table className="w-full">
|
||||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
{proxiesTable.getHeaderGroups().map((headerGroup) => (
|
{proxiesTable.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
@@ -1251,7 +1264,7 @@ export function ProxyManagementDialog({
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Table className="min-w-max">
|
<Table className="w-full">
|
||||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||||
{vpnsTable.getHeaderGroups().map((headerGroup) => (
|
{vpnsTable.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { FaDownload } from "react-icons/fa";
|
import { FaDownload } from "react-icons/fa";
|
||||||
import { FiWifi } from "react-icons/fi";
|
import { FiWifi } from "react-icons/fi";
|
||||||
import { GoGear, GoKebabHorizontal } from "react-icons/go";
|
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 { cn } from "@/lib/utils";
|
||||||
import { Logo } from "./icons/logo";
|
import { Logo } from "./icons/logo";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||||
@@ -19,7 +26,8 @@ export type AppPage =
|
|||||||
| "settings"
|
| "settings"
|
||||||
| "integrations"
|
| "integrations"
|
||||||
| "account"
|
| "account"
|
||||||
| "import";
|
| "import"
|
||||||
|
| "shortcuts";
|
||||||
|
|
||||||
const CLICK_THRESHOLD = 5;
|
const CLICK_THRESHOLD = 5;
|
||||||
const CLICK_WINDOW_MS = 2000;
|
const CLICK_WINDOW_MS = 2000;
|
||||||
@@ -257,6 +265,12 @@ const MORE_ITEMS: MoreMenuItem[] = [
|
|||||||
labelKey: "rail.more.importProfile",
|
labelKey: "rail.more.importProfile",
|
||||||
hintKey: "rail.more.importProfileHint",
|
hintKey: "rail.more.importProfileHint",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
page: "shortcuts",
|
||||||
|
Icon: LuKeyboard,
|
||||||
|
labelKey: "rail.more.keyboardShortcuts",
|
||||||
|
hintKey: "rail.more.keyboardShortcutsHint",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||||
|
|||||||
@@ -464,6 +464,7 @@ export function SettingsDialog({
|
|||||||
| "fr"
|
| "fr"
|
||||||
| "zh"
|
| "zh"
|
||||||
| "ja"
|
| "ja"
|
||||||
|
| "ko"
|
||||||
| "ru"),
|
| "ru"),
|
||||||
);
|
);
|
||||||
setOriginalLanguage(selectedLanguage);
|
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";
|
"use client";
|
||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
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 { 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 { LoadingButton } from "@/components/loading-button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -19,6 +22,8 @@ interface UnsyncedEntityCounts {
|
|||||||
proxies: number;
|
proxies: number;
|
||||||
groups: number;
|
groups: number;
|
||||||
vpns: number;
|
vpns: number;
|
||||||
|
extensions: number;
|
||||||
|
extension_groups: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SyncAllDialogProps {
|
interface SyncAllDialogProps {
|
||||||
@@ -67,27 +72,55 @@ export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
|
|||||||
}
|
}
|
||||||
}, [onClose, t]);
|
}, [onClose, t]);
|
||||||
|
|
||||||
const totalCount =
|
const items = useMemo(() => {
|
||||||
(counts?.proxies ?? 0) + (counts?.groups ?? 0) + (counts?.vpns ?? 0);
|
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) {
|
if (!isLoading && totalCount === 0) {
|
||||||
return null;
|
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 (
|
return (
|
||||||
<Dialog open={isOpen && totalCount > 0} onOpenChange={onClose}>
|
<Dialog
|
||||||
|
open={isOpen && (isLoading || totalCount > 0)}
|
||||||
|
onOpenChange={onClose}
|
||||||
|
>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("syncAll.title")}</DialogTitle>
|
<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 className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-4">
|
<div className="grid grid-cols-2 gap-2 py-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
{items.map(({ key, count, label, Icon }) => (
|
||||||
{t("syncAll.itemsList", { items: parts.join(", ") })}
|
<div
|
||||||
</p>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -11,19 +11,18 @@ const MotionThumb = motion.create(SwitchPrimitive.Thumb);
|
|||||||
type AnimatedSwitchProps = React.ComponentProps<typeof SwitchPrimitive.Root>;
|
type AnimatedSwitchProps = React.ComponentProps<typeof SwitchPrimitive.Root>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle switch with a thumb that slides between the off (left) and on
|
* Switch whose thumb actually slides between off and on. The Root flips
|
||||||
* (right) positions and squashes wider while pressed. Animated via Framer
|
* its flex alignment on `data-state=checked`, which moves the Thumb's
|
||||||
* Motion — no layout shift when the parent's width changes, and the
|
* layout box; Framer Motion's `layout` prop tweens between the two
|
||||||
* pressed state is purely visual so external onCheckedChange semantics
|
* positions. The thumb also squashes wider while pressed.
|
||||||
* stay identical to a Radix Switch.
|
|
||||||
*/
|
*/
|
||||||
function AnimatedSwitch({ className, ...props }: AnimatedSwitchProps) {
|
function AnimatedSwitch({ className, ...props }: AnimatedSwitchProps) {
|
||||||
return (
|
return (
|
||||||
<SwitchPrimitive.Root
|
<SwitchPrimitive.Root
|
||||||
data-slot="animated-switch"
|
data-slot="animated-switch"
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent",
|
"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",
|
"bg-input data-[state=checked]:bg-primary data-[state=checked]:justify-end",
|
||||||
"transition-colors duration-200 ease-out",
|
"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",
|
"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",
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
@@ -39,8 +38,7 @@ function AnimatedSwitch({ className, ...props }: AnimatedSwitchProps) {
|
|||||||
)}
|
)}
|
||||||
layout
|
layout
|
||||||
transition={{ type: "spring", stiffness: 700, damping: 32, mass: 0.5 }}
|
transition={{ type: "spring", stiffness: 700, damping: 32, mass: 0.5 }}
|
||||||
whileTap={{ width: 22 }}
|
whileTap={{ width: 20 }}
|
||||||
style={{ marginLeft: 2, marginRight: 2 }}
|
|
||||||
/>
|
/>
|
||||||
</SwitchPrimitive.Root>
|
</SwitchPrimitive.Root>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,10 +34,14 @@ function CommandDialog({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
children,
|
children,
|
||||||
|
filter,
|
||||||
|
shouldFilter,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Dialog> & {
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
filter?: React.ComponentProps<typeof CommandPrimitive>["filter"];
|
||||||
|
shouldFilter?: React.ComponentProps<typeof CommandPrimitive>["shouldFilter"];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const resolvedTitle = title ?? t("common.commandPalette.title");
|
const resolvedTitle = title ?? t("common.commandPalette.title");
|
||||||
@@ -50,7 +54,11 @@ function CommandDialog({
|
|||||||
<DialogDescription>{resolvedDescription}</DialogDescription>
|
<DialogDescription>{resolvedDescription}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogContent className="overflow-hidden p-0">
|
<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}
|
{children}
|
||||||
</Command>
|
</Command>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import en from "./locales/en.json";
|
|||||||
import es from "./locales/es.json";
|
import es from "./locales/es.json";
|
||||||
import fr from "./locales/fr.json";
|
import fr from "./locales/fr.json";
|
||||||
import ja from "./locales/ja.json";
|
import ja from "./locales/ja.json";
|
||||||
|
import ko from "./locales/ko.json";
|
||||||
import pt from "./locales/pt.json";
|
import pt from "./locales/pt.json";
|
||||||
import ru from "./locales/ru.json";
|
import ru from "./locales/ru.json";
|
||||||
import zh from "./locales/zh.json";
|
import zh from "./locales/zh.json";
|
||||||
@@ -16,6 +17,7 @@ export const SUPPORTED_LANGUAGES = [
|
|||||||
{ code: "fr", name: "French", nativeName: "Français" },
|
{ code: "fr", name: "French", nativeName: "Français" },
|
||||||
{ code: "zh", name: "Chinese", nativeName: "中文" },
|
{ code: "zh", name: "Chinese", nativeName: "中文" },
|
||||||
{ code: "ja", name: "Japanese", nativeName: "日本語" },
|
{ code: "ja", name: "Japanese", nativeName: "日本語" },
|
||||||
|
{ code: "ko", name: "Korean", nativeName: "한국어" },
|
||||||
{ code: "ru", name: "Russian", nativeName: "Русский" },
|
{ code: "ru", name: "Russian", nativeName: "Русский" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -61,6 +63,7 @@ const resources = {
|
|||||||
fr: { translation: fr },
|
fr: { translation: fr },
|
||||||
zh: { translation: zh },
|
zh: { translation: zh },
|
||||||
ja: { translation: ja },
|
ja: { translation: ja },
|
||||||
|
ko: { translation: ko },
|
||||||
ru: { translation: ru },
|
ru: { translation: ru },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+52
-10
@@ -1070,16 +1070,16 @@
|
|||||||
"syncAll": {
|
"syncAll": {
|
||||||
"title": "Enable Sync for Existing Items",
|
"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?",
|
"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",
|
"enableAll": "Enable All",
|
||||||
"skip": "Skip",
|
"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": {
|
"crossOs": {
|
||||||
"viewOnly": "This profile was created on {{os}} and is not supported on this system",
|
"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.",
|
"profileLocked": "Profile is locked. Enter the password first.",
|
||||||
"invalidProfileId": "Invalid profile id",
|
"invalidProfileId": "Invalid profile id",
|
||||||
"passwordTooShort": "Password must be at least {{min}} characters",
|
"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}}",
|
"internal": "Something went wrong: {{detail}}",
|
||||||
"invalidLaunchHookUrl": "Invalid launch hook URL. Use a full http:// or https:// URL.",
|
"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.",
|
"cookieDbLocked": "Could not read cookies — the database is locked. Close the browser and try again.",
|
||||||
@@ -1803,7 +1811,9 @@
|
|||||||
"label": "More",
|
"label": "More",
|
||||||
"closeAriaLabel": "Close menu",
|
"closeAriaLabel": "Close menu",
|
||||||
"importProfile": "Import profile",
|
"importProfile": "Import profile",
|
||||||
"importProfileHint": "Bring profiles from another tool"
|
"importProfileHint": "Bring profiles from another tool",
|
||||||
|
"keyboardShortcuts": "Keyboard shortcuts",
|
||||||
|
"keyboardShortcutsHint": "View all shortcuts"
|
||||||
},
|
},
|
||||||
"network": "Network",
|
"network": "Network",
|
||||||
"integrations": "Integrations",
|
"integrations": "Integrations",
|
||||||
@@ -1817,7 +1827,8 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"integrations": "Integrations",
|
"integrations": "Integrations",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"import": "Import profile"
|
"import": "Import profile",
|
||||||
|
"shortcuts": "Keyboard shortcuts"
|
||||||
},
|
},
|
||||||
"encryption": {
|
"encryption": {
|
||||||
"required": {
|
"required": {
|
||||||
@@ -1870,5 +1881,36 @@
|
|||||||
"testConnection": "Test connection",
|
"testConnection": "Test connection",
|
||||||
"disconnect": "Disconnect"
|
"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": {
|
"syncAll": {
|
||||||
"title": "Activar sincronización para elementos existentes",
|
"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?",
|
"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",
|
"enableAll": "Activar todos",
|
||||||
"skip": "Omitir",
|
"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": {
|
"crossOs": {
|
||||||
"viewOnly": "Este perfil fue creado en {{os}} y no es compatible con este sistema",
|
"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.",
|
"profileLocked": "El perfil está bloqueado. Introduce la contraseña primero.",
|
||||||
"invalidProfileId": "ID de perfil no válido",
|
"invalidProfileId": "ID de perfil no válido",
|
||||||
"passwordTooShort": "La contraseña debe tener al menos {{min}} caracteres",
|
"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}}",
|
"internal": "Algo salió mal: {{detail}}",
|
||||||
"invalidLaunchHookUrl": "URL del hook de inicio no válida. Usa una URL completa http:// o https://.",
|
"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.",
|
"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",
|
"label": "Más",
|
||||||
"closeAriaLabel": "Cerrar menú",
|
"closeAriaLabel": "Cerrar menú",
|
||||||
"importProfile": "Importar perfil",
|
"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",
|
"network": "Red",
|
||||||
"integrations": "Integraciones",
|
"integrations": "Integraciones",
|
||||||
@@ -1817,7 +1827,8 @@
|
|||||||
"settings": "Ajustes",
|
"settings": "Ajustes",
|
||||||
"integrations": "Integraciones",
|
"integrations": "Integraciones",
|
||||||
"account": "Cuenta",
|
"account": "Cuenta",
|
||||||
"import": "Importar perfil"
|
"import": "Importar perfil",
|
||||||
|
"shortcuts": "Atajos de teclado"
|
||||||
},
|
},
|
||||||
"encryption": {
|
"encryption": {
|
||||||
"required": {
|
"required": {
|
||||||
@@ -1870,5 +1881,36 @@
|
|||||||
"testConnection": "Probar conexión",
|
"testConnection": "Probar conexión",
|
||||||
"disconnect": "Desconectar"
|
"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": {
|
"syncAll": {
|
||||||
"title": "Activer la synchronisation pour les éléments existants",
|
"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 ?",
|
"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",
|
"enableAll": "Tout activer",
|
||||||
"skip": "Ignorer",
|
"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": {
|
"crossOs": {
|
||||||
"viewOnly": "Ce profil a été créé sur {{os}} et n'est pas pris en charge sur ce système",
|
"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.",
|
"profileLocked": "Le profil est verrouillé. Entrez d'abord le mot de passe.",
|
||||||
"invalidProfileId": "Identifiant de profil non valide",
|
"invalidProfileId": "Identifiant de profil non valide",
|
||||||
"passwordTooShort": "Le mot de passe doit comporter au moins {{min}} caractères",
|
"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}}",
|
"internal": "Une erreur s'est produite : {{detail}}",
|
||||||
"invalidLaunchHookUrl": "URL du hook de lancement invalide. Utilisez une URL http:// ou https:// complète.",
|
"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.",
|
"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",
|
"label": "Plus",
|
||||||
"closeAriaLabel": "Fermer le menu",
|
"closeAriaLabel": "Fermer le menu",
|
||||||
"importProfile": "Importer un profil",
|
"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",
|
"network": "Réseau",
|
||||||
"integrations": "Intégrations",
|
"integrations": "Intégrations",
|
||||||
@@ -1817,7 +1827,8 @@
|
|||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"integrations": "Intégrations",
|
"integrations": "Intégrations",
|
||||||
"account": "Compte",
|
"account": "Compte",
|
||||||
"import": "Importer un profil"
|
"import": "Importer un profil",
|
||||||
|
"shortcuts": "Raccourcis clavier"
|
||||||
},
|
},
|
||||||
"encryption": {
|
"encryption": {
|
||||||
"required": {
|
"required": {
|
||||||
@@ -1870,5 +1881,36 @@
|
|||||||
"testConnection": "Tester la connexion",
|
"testConnection": "Tester la connexion",
|
||||||
"disconnect": "Déconnecter"
|
"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": {
|
"syncAll": {
|
||||||
"title": "既存アイテムの同期を有効にする",
|
"title": "既存アイテムの同期を有効にする",
|
||||||
"description": "同期されていないアイテムがあります。すべての同期を有効にしますか?",
|
"description": "同期されていないアイテムがあります。すべての同期を有効にしますか?",
|
||||||
"itemsList": "未同期アイテム: {{items}}",
|
|
||||||
"proxies": "{{count}}個のプロキシ",
|
|
||||||
"proxies_plural": "{{count}}個のプロキシ",
|
|
||||||
"groups": "{{count}}個のグループ",
|
|
||||||
"groups_plural": "{{count}}個のグループ",
|
|
||||||
"vpns": "{{count}}個のVPN",
|
|
||||||
"vpns_plural": "{{count}}個のVPN",
|
|
||||||
"enableAll": "すべて有効にする",
|
"enableAll": "すべて有効にする",
|
||||||
"skip": "スキップ",
|
"skip": "スキップ",
|
||||||
"success": "すべてのアイテムの同期が有効になりました"
|
"success": "すべてのアイテムの同期が有効になりました",
|
||||||
|
"labels": {
|
||||||
|
"proxies": "プロキシ",
|
||||||
|
"vpns": "VPN",
|
||||||
|
"groups": "グループ",
|
||||||
|
"extensions": "拡張機能",
|
||||||
|
"extensionGroups": "拡張機能グループ"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"crossOs": {
|
"crossOs": {
|
||||||
"viewOnly": "このプロファイルは{{os}}で作成されたもので、このシステムではサポートされていません",
|
"viewOnly": "このプロファイルは{{os}}で作成されたもので、このシステムではサポートされていません",
|
||||||
@@ -1788,6 +1788,14 @@
|
|||||||
"profileLocked": "プロファイルはロックされています。先にパスワードを入力してください。",
|
"profileLocked": "プロファイルはロックされています。先にパスワードを入力してください。",
|
||||||
"invalidProfileId": "無効なプロファイルIDです",
|
"invalidProfileId": "無効なプロファイルIDです",
|
||||||
"passwordTooShort": "パスワードは {{min}} 文字以上必要です",
|
"passwordTooShort": "パスワードは {{min}} 文字以上必要です",
|
||||||
|
"proxyNotFound": "プロキシが見つかりません",
|
||||||
|
"groupNotFound": "グループが見つかりません",
|
||||||
|
"vpnNotFound": "VPNが見つかりません",
|
||||||
|
"extensionNotFound": "拡張機能が見つかりません",
|
||||||
|
"extensionGroupNotFound": "拡張機能グループが見つかりません",
|
||||||
|
"cannotModifyCloudManagedProxy": "クラウド管理のプロキシの同期は変更できません",
|
||||||
|
"syncLockedByProfile": "同期済みプロファイルで使用中のため、同期を無効にできません",
|
||||||
|
"syncNotConfigured": "同期が設定されていません。サインインするか、セルフホストサーバーを設定してください。",
|
||||||
"internal": "問題が発生しました: {{detail}}",
|
"internal": "問題が発生しました: {{detail}}",
|
||||||
"invalidLaunchHookUrl": "起動フックURLが無効です。完全な http:// または https:// URL を使用してください。",
|
"invalidLaunchHookUrl": "起動フックURLが無効です。完全な http:// または https:// URL を使用してください。",
|
||||||
"cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。",
|
"cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。",
|
||||||
@@ -1803,7 +1811,9 @@
|
|||||||
"label": "その他",
|
"label": "その他",
|
||||||
"closeAriaLabel": "メニューを閉じる",
|
"closeAriaLabel": "メニューを閉じる",
|
||||||
"importProfile": "プロファイルをインポート",
|
"importProfile": "プロファイルをインポート",
|
||||||
"importProfileHint": "別のツールから取り込む"
|
"importProfileHint": "別のツールから取り込む",
|
||||||
|
"keyboardShortcuts": "キーボードショートカット",
|
||||||
|
"keyboardShortcutsHint": "すべてのショートカットを表示"
|
||||||
},
|
},
|
||||||
"network": "ネットワーク",
|
"network": "ネットワーク",
|
||||||
"integrations": "連携",
|
"integrations": "連携",
|
||||||
@@ -1817,7 +1827,8 @@
|
|||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"integrations": "連携",
|
"integrations": "連携",
|
||||||
"account": "アカウント",
|
"account": "アカウント",
|
||||||
"import": "プロファイルをインポート"
|
"import": "プロファイルをインポート",
|
||||||
|
"shortcuts": "キーボードショートカット"
|
||||||
},
|
},
|
||||||
"encryption": {
|
"encryption": {
|
||||||
"required": {
|
"required": {
|
||||||
@@ -1870,5 +1881,36 @@
|
|||||||
"testConnection": "接続をテスト",
|
"testConnection": "接続をテスト",
|
||||||
"disconnect": "切断"
|
"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": {
|
"syncAll": {
|
||||||
"title": "Ativar sincronização para itens existentes",
|
"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?",
|
"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",
|
"enableAll": "Ativar todos",
|
||||||
"skip": "Pular",
|
"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": {
|
"crossOs": {
|
||||||
"viewOnly": "Este perfil foi criado em {{os}} e não é compatível com este sistema",
|
"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.",
|
"profileLocked": "O perfil está bloqueado. Digite a senha primeiro.",
|
||||||
"invalidProfileId": "ID de perfil inválido",
|
"invalidProfileId": "ID de perfil inválido",
|
||||||
"passwordTooShort": "A senha deve ter pelo menos {{min}} caracteres",
|
"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}}",
|
"internal": "Algo deu errado: {{detail}}",
|
||||||
"invalidLaunchHookUrl": "URL do hook de inicialização inválida. Use uma URL completa http:// ou https://.",
|
"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.",
|
"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",
|
"label": "Mais",
|
||||||
"closeAriaLabel": "Fechar menu",
|
"closeAriaLabel": "Fechar menu",
|
||||||
"importProfile": "Importar perfil",
|
"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",
|
"network": "Rede",
|
||||||
"integrations": "Integrações",
|
"integrations": "Integrações",
|
||||||
@@ -1817,7 +1827,8 @@
|
|||||||
"settings": "Configurações",
|
"settings": "Configurações",
|
||||||
"integrations": "Integrações",
|
"integrations": "Integrações",
|
||||||
"account": "Conta",
|
"account": "Conta",
|
||||||
"import": "Importar perfil"
|
"import": "Importar perfil",
|
||||||
|
"shortcuts": "Atalhos de teclado"
|
||||||
},
|
},
|
||||||
"encryption": {
|
"encryption": {
|
||||||
"required": {
|
"required": {
|
||||||
@@ -1870,5 +1881,36 @@
|
|||||||
"testConnection": "Testar conexão",
|
"testConnection": "Testar conexão",
|
||||||
"disconnect": "Desconectar"
|
"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": {
|
"syncAll": {
|
||||||
"title": "Включить синхронизацию для существующих элементов",
|
"title": "Включить синхронизацию для существующих элементов",
|
||||||
"description": "У вас есть элементы, которые не синхронизируются. Хотите включить синхронизацию для всех?",
|
"description": "У вас есть элементы, которые не синхронизируются. Хотите включить синхронизацию для всех?",
|
||||||
"itemsList": "Несинхронизированные элементы: {{items}}",
|
|
||||||
"proxies": "{{count}} прокси",
|
|
||||||
"proxies_plural": "{{count}} прокси",
|
|
||||||
"groups": "{{count}} группа",
|
|
||||||
"groups_plural": "{{count}} групп",
|
|
||||||
"vpns": "{{count}} VPN",
|
|
||||||
"vpns_plural": "{{count}} VPN",
|
|
||||||
"enableAll": "Включить все",
|
"enableAll": "Включить все",
|
||||||
"skip": "Пропустить",
|
"skip": "Пропустить",
|
||||||
"success": "Синхронизация включена для всех элементов"
|
"success": "Синхронизация включена для всех элементов",
|
||||||
|
"labels": {
|
||||||
|
"proxies": "Прокси",
|
||||||
|
"vpns": "VPN",
|
||||||
|
"groups": "Группы",
|
||||||
|
"extensions": "Расширения",
|
||||||
|
"extensionGroups": "Группы расширений"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"crossOs": {
|
"crossOs": {
|
||||||
"viewOnly": "Этот профиль был создан на {{os}} и не поддерживается в этой системе",
|
"viewOnly": "Этот профиль был создан на {{os}} и не поддерживается в этой системе",
|
||||||
@@ -1788,6 +1788,14 @@
|
|||||||
"profileLocked": "Профиль заблокирован. Сначала введите пароль.",
|
"profileLocked": "Профиль заблокирован. Сначала введите пароль.",
|
||||||
"invalidProfileId": "Недействительный идентификатор профиля",
|
"invalidProfileId": "Недействительный идентификатор профиля",
|
||||||
"passwordTooShort": "Пароль должен быть не короче {{min}} символов",
|
"passwordTooShort": "Пароль должен быть не короче {{min}} символов",
|
||||||
|
"proxyNotFound": "Прокси не найден",
|
||||||
|
"groupNotFound": "Группа не найдена",
|
||||||
|
"vpnNotFound": "VPN не найден",
|
||||||
|
"extensionNotFound": "Расширение не найдено",
|
||||||
|
"extensionGroupNotFound": "Группа расширений не найдена",
|
||||||
|
"cannotModifyCloudManagedProxy": "Невозможно изменить синхронизацию для облачного прокси",
|
||||||
|
"syncLockedByProfile": "Невозможно отключить синхронизацию, пока используется синхронизированными профилями",
|
||||||
|
"syncNotConfigured": "Синхронизация не настроена. Войдите или настройте собственный сервер.",
|
||||||
"internal": "Что-то пошло не так: {{detail}}",
|
"internal": "Что-то пошло не так: {{detail}}",
|
||||||
"invalidLaunchHookUrl": "Неверный URL хука запуска. Используйте полный URL http:// или https://.",
|
"invalidLaunchHookUrl": "Неверный URL хука запуска. Используйте полный URL http:// или https://.",
|
||||||
"cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.",
|
"cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.",
|
||||||
@@ -1803,7 +1811,9 @@
|
|||||||
"label": "Ещё",
|
"label": "Ещё",
|
||||||
"closeAriaLabel": "Закрыть меню",
|
"closeAriaLabel": "Закрыть меню",
|
||||||
"importProfile": "Импорт профиля",
|
"importProfile": "Импорт профиля",
|
||||||
"importProfileHint": "Перенести профили из другого инструмента"
|
"importProfileHint": "Перенести профили из другого инструмента",
|
||||||
|
"keyboardShortcuts": "Сочетания клавиш",
|
||||||
|
"keyboardShortcutsHint": "Показать все сочетания"
|
||||||
},
|
},
|
||||||
"network": "Сеть",
|
"network": "Сеть",
|
||||||
"integrations": "Интеграции",
|
"integrations": "Интеграции",
|
||||||
@@ -1817,7 +1827,8 @@
|
|||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"integrations": "Интеграции",
|
"integrations": "Интеграции",
|
||||||
"account": "Аккаунт",
|
"account": "Аккаунт",
|
||||||
"import": "Импорт профиля"
|
"import": "Импорт профиля",
|
||||||
|
"shortcuts": "Сочетания клавиш"
|
||||||
},
|
},
|
||||||
"encryption": {
|
"encryption": {
|
||||||
"required": {
|
"required": {
|
||||||
@@ -1870,5 +1881,36 @@
|
|||||||
"testConnection": "Проверить соединение",
|
"testConnection": "Проверить соединение",
|
||||||
"disconnect": "Отключить"
|
"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": {
|
"syncAll": {
|
||||||
"title": "为现有项目启用同步",
|
"title": "为现有项目启用同步",
|
||||||
"description": "您有未同步的项目。是否要为所有项目启用同步?",
|
"description": "您有未同步的项目。是否要为所有项目启用同步?",
|
||||||
"itemsList": "未同步项目: {{items}}",
|
|
||||||
"proxies": "{{count}} 个代理",
|
|
||||||
"proxies_plural": "{{count}} 个代理",
|
|
||||||
"groups": "{{count}} 个分组",
|
|
||||||
"groups_plural": "{{count}} 个分组",
|
|
||||||
"vpns": "{{count}} 个 VPN",
|
|
||||||
"vpns_plural": "{{count}} 个 VPN",
|
|
||||||
"enableAll": "全部启用",
|
"enableAll": "全部启用",
|
||||||
"skip": "跳过",
|
"skip": "跳过",
|
||||||
"success": "已为所有项目启用同步"
|
"success": "已为所有项目启用同步",
|
||||||
|
"labels": {
|
||||||
|
"proxies": "代理",
|
||||||
|
"vpns": "VPN",
|
||||||
|
"groups": "分组",
|
||||||
|
"extensions": "扩展",
|
||||||
|
"extensionGroups": "扩展分组"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"crossOs": {
|
"crossOs": {
|
||||||
"viewOnly": "此配置文件在 {{os}} 上创建,不受此系统支持",
|
"viewOnly": "此配置文件在 {{os}} 上创建,不受此系统支持",
|
||||||
@@ -1788,6 +1788,14 @@
|
|||||||
"profileLocked": "配置文件已锁定。请先输入密码。",
|
"profileLocked": "配置文件已锁定。请先输入密码。",
|
||||||
"invalidProfileId": "配置文件 ID 无效",
|
"invalidProfileId": "配置文件 ID 无效",
|
||||||
"passwordTooShort": "密码至少需要 {{min}} 个字符",
|
"passwordTooShort": "密码至少需要 {{min}} 个字符",
|
||||||
|
"proxyNotFound": "未找到代理",
|
||||||
|
"groupNotFound": "未找到分组",
|
||||||
|
"vpnNotFound": "未找到 VPN",
|
||||||
|
"extensionNotFound": "未找到扩展",
|
||||||
|
"extensionGroupNotFound": "未找到扩展分组",
|
||||||
|
"cannotModifyCloudManagedProxy": "无法修改云管理代理的同步",
|
||||||
|
"syncLockedByProfile": "在被已同步的配置文件使用时无法禁用同步",
|
||||||
|
"syncNotConfigured": "同步未配置。请先登录或配置自托管服务器。",
|
||||||
"internal": "出现问题:{{detail}}",
|
"internal": "出现问题:{{detail}}",
|
||||||
"invalidLaunchHookUrl": "启动钩子 URL 无效。请使用完整的 http:// 或 https:// URL。",
|
"invalidLaunchHookUrl": "启动钩子 URL 无效。请使用完整的 http:// 或 https:// URL。",
|
||||||
"cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。",
|
"cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。",
|
||||||
@@ -1803,7 +1811,9 @@
|
|||||||
"label": "更多",
|
"label": "更多",
|
||||||
"closeAriaLabel": "关闭菜单",
|
"closeAriaLabel": "关闭菜单",
|
||||||
"importProfile": "导入配置文件",
|
"importProfile": "导入配置文件",
|
||||||
"importProfileHint": "从其他工具导入"
|
"importProfileHint": "从其他工具导入",
|
||||||
|
"keyboardShortcuts": "键盘快捷键",
|
||||||
|
"keyboardShortcutsHint": "查看所有快捷键"
|
||||||
},
|
},
|
||||||
"network": "网络",
|
"network": "网络",
|
||||||
"integrations": "集成",
|
"integrations": "集成",
|
||||||
@@ -1817,7 +1827,8 @@
|
|||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"integrations": "集成",
|
"integrations": "集成",
|
||||||
"account": "账户",
|
"account": "账户",
|
||||||
"import": "导入配置文件"
|
"import": "导入配置文件",
|
||||||
|
"shortcuts": "键盘快捷键"
|
||||||
},
|
},
|
||||||
"encryption": {
|
"encryption": {
|
||||||
"required": {
|
"required": {
|
||||||
@@ -1870,5 +1881,36 @@
|
|||||||
"testConnection": "测试连接",
|
"testConnection": "测试连接",
|
||||||
"disconnect": "断开连接"
|
"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_LOCKED"
|
||||||
| "COOKIE_DB_UNAVAILABLE"
|
| "COOKIE_DB_UNAVAILABLE"
|
||||||
| "SELF_HOSTED_REQUIRES_LOGOUT"
|
| "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";
|
| "INTERNAL_ERROR";
|
||||||
|
|
||||||
export interface BackendError {
|
export interface BackendError {
|
||||||
@@ -96,6 +104,22 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
|||||||
return t("backendErrors.cookieDbUnavailable");
|
return t("backendErrors.cookieDbUnavailable");
|
||||||
case "SELF_HOSTED_REQUIRES_LOGOUT":
|
case "SELF_HOSTED_REQUIRES_LOGOUT":
|
||||||
return t("backendErrors.selfHostedRequiresLogout");
|
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":
|
case "INTERNAL_ERROR":
|
||||||
return t("backendErrors.internal", {
|
return t("backendErrors.internal", {
|
||||||
detail: parsed.params?.detail ?? "",
|
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 React from "react";
|
||||||
import { type ExternalToast, toast as sonnerToast } from "sonner";
|
import { type ExternalToast, toast as sonnerToast } from "sonner";
|
||||||
import { UnifiedToast } from "@/components/custom-toast";
|
import { UnifiedToast } from "@/components/custom-toast";
|
||||||
@@ -259,7 +260,7 @@ export function showSyncProgressToast(
|
|||||||
failed_count: number;
|
failed_count: number;
|
||||||
phase: string;
|
phase: string;
|
||||||
},
|
},
|
||||||
options?: { id?: string },
|
options?: { id?: string; profileId?: string },
|
||||||
) {
|
) {
|
||||||
return showToast({
|
return showToast({
|
||||||
type: "sync-progress",
|
type: "sync-progress",
|
||||||
@@ -268,6 +269,15 @@ export function showSyncProgressToast(
|
|||||||
id: options?.id,
|
id: options?.id,
|
||||||
duration: Number.POSITIVE_INFINITY,
|
duration: Number.POSITIVE_INFINITY,
|
||||||
onCancel: () => {
|
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) {
|
if (options?.id) {
|
||||||
dismissToast(options.id);
|
dismissToast(options.id);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user