mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-12 09:47:51 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36263eac04 | |||
| 9e777ed37b | |||
| 4d59805989 | |||
| 28d135de06 | |||
| d234172d0a | |||
| 6cd257c40b | |||
| 7446f678d4 | |||
| 72e2b99b9e | |||
| 98b83aaf5a | |||
| 99074280ea | |||
| 85586ed8fa |
@@ -34,7 +34,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
name: Compliance Close
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 30 minutes; the actual close decision uses comment age, so the cron
|
||||
# cadence only bounds how stale the closure can get past the 24-hour mark.
|
||||
- cron: "*/30 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
close-non-compliant:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close non-compliant issues and PRs after 24 hours
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const { data: items } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: 'needs:compliance',
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
core.info('No open issues/PRs with needs:compliance label');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const window_ms = 24 * 60 * 60 * 1000;
|
||||
|
||||
for (const item of items) {
|
||||
const isPR = !!item.pull_request;
|
||||
const kind = isPR ? 'PR' : 'issue';
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: item.number,
|
||||
});
|
||||
|
||||
// Use the OLDEST compliance sentinel as the start of the 24-hour
|
||||
// window so back-and-forth edits don't reset the clock.
|
||||
const sentinel = comments
|
||||
.filter(c => c.body && c.body.includes('<!-- issue-compliance -->'))
|
||||
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at))[0];
|
||||
|
||||
if (!sentinel) {
|
||||
core.info(`${kind} #${item.number} has needs:compliance label but no compliance comment; skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const age_ms = now - new Date(sentinel.created_at).getTime();
|
||||
if (age_ms < window_ms) {
|
||||
const hours = (age_ms / (60 * 60 * 1000)).toFixed(1);
|
||||
core.info(`${kind} #${item.number} still within 24-hour window (${hours}h elapsed)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const closeMessage = isPR
|
||||
? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/main/CONTRIBUTING.md) within the 24-hour window.\n\nFeel free to open a new pull request that follows our guidelines.'
|
||||
: 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/main/CONTRIBUTING.md) within the 24-hour window.\n\nFeel free to open a new issue that follows our issue templates.';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: item.number,
|
||||
body: closeMessage,
|
||||
});
|
||||
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: item.number,
|
||||
name: 'needs:compliance',
|
||||
});
|
||||
} catch (e) {
|
||||
core.info(`Could not remove needs:compliance label from #${item.number}: ${e.message}`);
|
||||
}
|
||||
|
||||
if (isPR) {
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: item.number,
|
||||
state: 'closed',
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: item.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
}
|
||||
|
||||
core.info(`Closed non-compliant ${kind} #${item.number} after 24-hour window`);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
name: Duplicate Issue Check
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
env:
|
||||
MODEL: z-ai/glm-5.1
|
||||
|
||||
jobs:
|
||||
check-duplicates:
|
||||
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:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
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
|
||||
|
||||
# Pull up to 150 open/closed issues for the LLM to compare against.
|
||||
# Exclude the issue under inspection and any PRs (gh issue list does
|
||||
# this naturally).
|
||||
gh issue list \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--state all \
|
||||
--limit 150 \
|
||||
--json number,title,state,body \
|
||||
--jq "[.[] | select(.number != $ISSUE_NUMBER) | {number, title, state, body: (.body[:400] // \"\")}]" \
|
||||
> /tmp/existing-issues.json
|
||||
|
||||
- name: Build prompt
|
||||
run: |
|
||||
cat > /tmp/system.txt <<'PROMPT'
|
||||
You are reviewing a new GitHub issue for two things — template compliance and possible duplicates. 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.
|
||||
|
||||
## Duplicates — flag candidates ONLY when at least one of these is true
|
||||
- Same error message, exception, or symptom
|
||||
- Same feature being requested
|
||||
- Same root cause area (e.g. "proxy disconnects on Camoufox/Windows")
|
||||
|
||||
Prefer false negatives over false positives. Two issues about Wayfern are not duplicates if they are about different features.
|
||||
|
||||
## Output schema
|
||||
{
|
||||
"is_compliant": true | false,
|
||||
"non_compliance_reasons": ["short bullet", ...],
|
||||
"duplicates": [{"number": 123, "reason": "short reason"}]
|
||||
}
|
||||
|
||||
Empty arrays are fine. If there is nothing to flag, return:
|
||||
{"is_compliant": true, "non_compliance_reasons": [], "duplicates": []}
|
||||
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 \
|
||||
--rawfile existing /tmp/existing-issues.json \
|
||||
'{
|
||||
model: $model,
|
||||
messages: [
|
||||
{ role: "system", content: $system_prompt },
|
||||
{ role: "user",
|
||||
content: ("New issue title: " + $title + "\n\nNew issue body:\n" + $body + "\n\nExisting issues (JSON array):\n" + $existing) }
|
||||
],
|
||||
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 no-op"
|
||||
cat /tmp/raw.txt
|
||||
echo '{"is_compliant": true, "non_compliance_reasons": [], "duplicates": []}' > /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 []
|
||||
dups = r.get('duplicates') 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.')
|
||||
|
||||
if dups:
|
||||
if parts:
|
||||
parts.append('')
|
||||
parts.append('---')
|
||||
parts.append('This issue might duplicate existing reports. Please check:')
|
||||
for d in dups:
|
||||
num = d.get('number')
|
||||
reason = d.get('reason', '').strip()
|
||||
if num:
|
||||
parts.append(f'- #{num}{" — " + reason if reason else ""}')
|
||||
|
||||
if not compliant:
|
||||
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)
|
||||
# Expose flags for downstream steps via GITHUB_OUTPUT-style write.
|
||||
with open(os.environ['GITHUB_OUTPUT'], 'a') as fh:
|
||||
fh.write(f'has_comment={"true" if comment else "false"}\n')
|
||||
fh.write(f'non_compliant={"true" if not compliant else "false"}\n')
|
||||
EOF
|
||||
id: build
|
||||
|
||||
- name: Post comment
|
||||
if: steps.build.outputs.has_comment == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
|
||||
|
||||
- name: Apply needs:compliance label
|
||||
if: steps.build.outputs.non_compliant == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "needs:compliance"
|
||||
|
||||
recheck-compliance:
|
||||
# When a flagged issue is edited, re-check. If now compliant: remove label,
|
||||
# delete the previous compliance comment, and thank the author. If still
|
||||
# non-compliant: leave label and post an updated note.
|
||||
if: >
|
||||
github.repository == 'zhom/donutbrowser' &&
|
||||
github.event.action == 'edited' &&
|
||||
contains(github.event.issue.labels.*.name, 'needs:compliance')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Gather context
|
||||
env:
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
run: |
|
||||
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
|
||||
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
|
||||
|
||||
- name: Build prompt
|
||||
run: |
|
||||
cat > /tmp/system.txt <<'PROMPT'
|
||||
You are re-checking a GitHub issue that was previously flagged as not meeting template requirements. Return ONLY a single JSON object, no prose, no markdown fences.
|
||||
|
||||
Project: Donut Browser. There are three valid templates:
|
||||
- Bug Report (Description + Operating System + Donut Browser version + Which browser is affected + Steps to reproduce + Error logs/screenshots fields)
|
||||
- Feature Request (description + verification checkbox)
|
||||
- Question (free form)
|
||||
|
||||
## Flag NON-compliant ONLY when at least one of these is true
|
||||
- The issue body is empty or contains only placeholder text from the template
|
||||
- The issue is an obvious AI-generated wall of text with no real specifics
|
||||
- A bug report has no reproduction information or no error description
|
||||
- A feature request gives no use case at all
|
||||
- The author left required fields empty (Operating System, Donut Browser version, Which browser is affected, Steps to reproduce on bug reports)
|
||||
|
||||
Do NOT flag for missing optional fields, missing screenshots, short titles, or stylistic issues. Be conservative.
|
||||
|
||||
## Output schema
|
||||
{
|
||||
"is_compliant": true | false,
|
||||
"non_compliance_reasons": ["short bullet", ...]
|
||||
}
|
||||
PROMPT
|
||||
|
||||
- name: Call OpenRouter
|
||||
env:
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
run: |
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg model "$MODEL" \
|
||||
--rawfile system_prompt /tmp/system.txt \
|
||||
--rawfile title /tmp/issue-title.txt \
|
||||
--rawfile body /tmp/issue-body.txt \
|
||||
'{
|
||||
model: $model,
|
||||
messages: [
|
||||
{ role: "system", content: $system_prompt },
|
||||
{ role: "user", content: ("Title: " + $title + "\n\nBody:\n" + $body) }
|
||||
],
|
||||
response_format: { type: "json_object" }
|
||||
}')
|
||||
|
||||
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")
|
||||
|
||||
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/raw.txt
|
||||
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
|
||||
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
|
||||
echo "::warning::Model returned non-JSON; assuming still non-compliant"
|
||||
echo '{"is_compliant": false, "non_compliance_reasons": ["unable to parse model output"]}' > /tmp/result.json
|
||||
fi
|
||||
|
||||
- name: Resolve compliance state
|
||||
id: resolve
|
||||
run: |
|
||||
IS_COMPLIANT=$(jq -r '.is_compliant // false' /tmp/result.json)
|
||||
echo "is_compliant=$IS_COMPLIANT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Clear compliance label and acknowledge fix
|
||||
if: steps.resolve.outputs.is_compliant == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --remove-label "needs:compliance" || true
|
||||
|
||||
# Delete the previous <!-- issue-compliance --> sentinel comment so
|
||||
# the thread is clean once the author has addressed the issue.
|
||||
COMMENT_ID=$(gh api "repos/$GITHUB_REPOSITORY/issues/$ISSUE_NUMBER/comments" \
|
||||
--jq '[.[] | select(.body | contains("<!-- issue-compliance -->"))][-1].id // empty')
|
||||
if [ -n "$COMMENT_ID" ]; then
|
||||
gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID" || true
|
||||
fi
|
||||
|
||||
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" \
|
||||
--body "Thanks for updating the issue."
|
||||
|
||||
- name: Build follow-up comment
|
||||
if: steps.resolve.outputs.is_compliant != 'true'
|
||||
run: |
|
||||
python3 - <<'EOF'
|
||||
import json
|
||||
r = json.load(open('/tmp/result.json'))
|
||||
reasons = r.get('non_compliance_reasons') or []
|
||||
parts = [
|
||||
'<!-- issue-compliance -->',
|
||||
'This issue still does not meet our [contributing guidelines](../blob/main/CONTRIBUTING.md).',
|
||||
'',
|
||||
'**What still needs to be fixed:**',
|
||||
]
|
||||
for reason in reasons:
|
||||
parts.append(f'- {reason}')
|
||||
parts.append('')
|
||||
parts.append('Please edit this issue to address the above within **24 hours**, or it will be automatically closed.')
|
||||
open('/tmp/comment.md', 'w').write('\n'.join(parts))
|
||||
EOF
|
||||
|
||||
- name: Post follow-up comment
|
||||
if: steps.resolve.outputs.is_compliant != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
|
||||
@@ -18,8 +18,8 @@ permissions:
|
||||
|
||||
env:
|
||||
# Single source of truth for the model used by both triage and composer.
|
||||
TRIAGE_MODEL: anthropic/claude-opus-4.7
|
||||
COMPOSER_MODEL: anthropic/claude-opus-4.7
|
||||
TRIAGE_MODEL: z-ai/glm-5.1
|
||||
COMPOSER_MODEL: z-ai/glm-5.1
|
||||
|
||||
jobs:
|
||||
analyze-issue:
|
||||
@@ -615,7 +615,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@8ba2a9171597262df9d19516c82a5e14f18f5c63 #v1.14.41
|
||||
uses: anomalyco/opencode/github@37f89b742907c43b20d38b68eabe65981a59690a #v1.15.3
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
scan-scheduled:
|
||||
name: Scheduled Security Scan
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
scan-pr:
|
||||
name: PR Security Scan
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Generate release notes with AI
|
||||
id: generate-notes
|
||||
if: steps.get-release.outputs.is-prerelease == 'false'
|
||||
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
|
||||
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
|
||||
with:
|
||||
prompt-file: .github/prompts/release-notes.prompt.yml
|
||||
input: |
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
security-scan:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
security-scan:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -23,4 +23,4 @@ jobs:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@5374cbf686e897b15713110e233094e2874de7ef #v1.46.1
|
||||
uses: crate-ci/typos@aca895bf05aec0cb7dffa6f94495e923224d9f17 #v1.46.2
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ donutbrowser/
|
||||
- After making changes, run `pnpm format && pnpm lint && pnpm test` at the root of the project
|
||||
- Always run this command before finishing a task to ensure the application isn't broken
|
||||
- `pnpm lint` includes spellcheck via [typos](https://github.com/crate-ci/typos). False positives can be allowlisted in `_typos.toml`
|
||||
- The full `pnpm test` output dumps every test name (≈400+ lines) which burns context for no signal. Filter:
|
||||
`pnpm test 2>&1 | grep -E "test result|panicked|FAILED"` — four "test result: ok" lines means everything passed.
|
||||
|
||||
## Code Quality
|
||||
|
||||
@@ -122,6 +124,34 @@ A `<Dialog>` becomes a first-class app sub-page (no modal overlay, no center pos
|
||||
|
||||
Reference implementations: `src/components/account-page.tsx`, `src/components/proxy-management-dialog.tsx`. Reuse the exact class strings — the overrides are tuned to match the rest of the sub-page chrome.
|
||||
|
||||
### Cross-component tab control
|
||||
|
||||
When a tabbed sub-page dialog needs to be opened to a specific tab by an external trigger (e.g. a keyboard shortcut that toggles `proxies` ↔ `vpns`), expose an `initialTab` prop and key the `Tabs` component off it. The `key` change forces a remount so the new tab is selected even though the internal `activeTab` state is otherwise sticky:
|
||||
|
||||
```tsx
|
||||
<AnimatedTabs key={initialTab} defaultValue={initialTab} ...>
|
||||
```
|
||||
|
||||
Reference implementations: `proxy-management-dialog.tsx`, `extension-management-dialog.tsx`, `integrations-dialog.tsx`. The owning page in `src/app/page.tsx` keeps one piece of `useState` per dialog (`proxyManagementInitialTab`, `extensionManagementInitialTab`, `integrationsInitialTab`) and flips it on repeated shortcut presses.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
All app-wide shortcuts live in `src/lib/shortcuts.ts`:
|
||||
|
||||
- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in all seven locales.
|
||||
- `formatShortcut(s)` returns platform-correct token strings (`["⌘", "K"]` on mac, `["Ctrl", "K"]` elsewhere) — used by both the shortcuts page and the command palette.
|
||||
- `matchesShortcut(s, event)` matches a real `KeyboardEvent` and rejects the wrong-platform modifier so Ctrl+K on macOS never fires a `mod: true` shortcut.
|
||||
- `matchesGroupDigit(event)` returns 1–9 if Mod+digit was pressed — group switching is dynamic (driven by `orderedGroupTargets` in `page.tsx`) and isn't in the `SHORTCUTS` table.
|
||||
|
||||
Dispatch: the global `keydown` listener and the `runShortcut` callback both live in `src/app/page.tsx`. To add a new static shortcut:
|
||||
|
||||
1. Append to `SHORTCUTS` in `src/lib/shortcuts.ts`. Add the `ShortcutId` variant.
|
||||
2. Add a `case "yourId":` in `runShortcut` in `page.tsx`.
|
||||
3. Add the icon mapping in `src/components/command-palette.tsx::ICONS`.
|
||||
4. Add `shortcuts.yourId` (label) to all seven locale files.
|
||||
|
||||
The command palette (Mod+K) is built on the shadcn `Command` primitive with a token-AND fuzzy filter — `fuzzyFilter` in `command-palette.tsx`. The `CommandDialog` wrapper now forwards `filter`/`shouldFilter` to the inner `Command` for callers that need custom matching.
|
||||
|
||||
## Singletons
|
||||
|
||||
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## v0.24.2 (2026-05-16)
|
||||
|
||||
### Features
|
||||
|
||||
- more mcp integrations
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- camoufox proxy pid connection
|
||||
|
||||
### Refactoring
|
||||
|
||||
- browser update
|
||||
- ui cleanup
|
||||
- cleanup
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: cleanup
|
||||
- chore: update flake.nix for v0.24.1 [skip ci] (#364)
|
||||
|
||||
|
||||
## v0.24.1 (2026-05-12)
|
||||
|
||||
### Refactoring
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_x64.dmg) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
@@ -58,15 +58,15 @@ brew install --cask donut
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_x64-portable.zip)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut-0.24.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut-0.24.1-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_aarch64.AppImage) |
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut-0.24.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut-0.24.2-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
|
||||
@@ -94,17 +94,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.24.1";
|
||||
releaseVersion = "0.24.2";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_amd64.AppImage";
|
||||
hash = "sha256-nJ4WmbXQcnXWDaneucOlwzZmlOOBx+G/qDeCHH6/Vno=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_amd64.AppImage";
|
||||
hash = "sha256-140PSB/1BLGUB4sI/RgfYe7uUjwRFWXtdSnUZz6Wr0U=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_aarch64.AppImage";
|
||||
hash = "sha256-aLzHAdn+o9YsnKtK5BpjjrzAAbp/itsN1QdELTpHyTQ=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_aarch64.AppImage";
|
||||
hash = "sha256-QPGV6XO0ugPAJSbPJrVwDsEb9lw3dcL6IdU17UCYH4E=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
Generated
+52
-18
@@ -871,6 +871,15 @@ dependencies = [
|
||||
"bzip2-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
|
||||
dependencies = [
|
||||
"libbz2-rs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2-sys"
|
||||
version = "0.1.13+1.0.8"
|
||||
@@ -1795,7 +1804,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"boringtun",
|
||||
"bzip2",
|
||||
"bzip2 0.6.1",
|
||||
"cbc",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
@@ -1824,7 +1833,7 @@ dependencies = [
|
||||
"objc2-app-kit",
|
||||
"once_cell",
|
||||
"playwright",
|
||||
"quick-xml",
|
||||
"quick-xml 0.40.1",
|
||||
"rand 0.10.1",
|
||||
"regex-lite",
|
||||
"reqwest 0.13.3",
|
||||
@@ -1858,7 +1867,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tray-icon 0.24.0",
|
||||
@@ -2213,9 +2222,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.28"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
@@ -3554,12 +3563,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kurbo"
|
||||
version = "0.13.0"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
|
||||
checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"euclid",
|
||||
"polycool",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
@@ -3605,6 +3615,12 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libbz2-rs-sys"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fc329e1457d97a9d58a4e2ca49e3be572431a7e096008efc2e3a3c19d428f4"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
@@ -4382,9 +4398,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.4"
|
||||
version = "5.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd"
|
||||
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
@@ -4660,18 +4676,18 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.12"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9"
|
||||
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.12"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389"
|
||||
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4742,7 +4758,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.14.0",
|
||||
"quick-xml",
|
||||
"quick-xml 0.39.4",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -4798,6 +4814,15 @@ dependencies = [
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polycool"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
@@ -5014,6 +5039,15 @@ name = "quick-xml"
|
||||
version = "0.39.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
@@ -8092,7 +8126,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quick-xml 0.39.4",
|
||||
"quote",
|
||||
]
|
||||
|
||||
@@ -9145,9 +9179,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
@@ -9225,7 +9259,7 @@ checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"aes 0.8.4",
|
||||
"arbitrary",
|
||||
"bzip2",
|
||||
"bzip2 0.5.2",
|
||||
"constant_time_eq 0.3.1",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
|
||||
@@ -100,12 +100,12 @@ playwright = { git = "https://github.com/zhom/playwright-rust", branch = "master
|
||||
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
|
||||
rusqlite = { version = "0.39", features = ["bundled"] }
|
||||
serde_yaml = "0.9"
|
||||
toml = "0.9"
|
||||
toml = "1.1"
|
||||
thiserror = "2.0"
|
||||
regex-lite = "0.1"
|
||||
tempfile = "3"
|
||||
maxminddb = "0.28"
|
||||
quick-xml = { version = "0.39", features = ["serialize"] }
|
||||
quick-xml = { version = "0.40", features = ["serialize"] }
|
||||
|
||||
# VPN support
|
||||
boringtun = "0.7"
|
||||
|
||||
@@ -662,24 +662,39 @@ impl CamoufoxManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Write explicit proxy prefs to user.js so Firefox always uses the local
|
||||
// donut-proxy and never falls back to stale proxy settings baked into prefs.js
|
||||
// from a previous session. user.js values override prefs.js on every launch.
|
||||
// Write explicit proxy + extension prefs to user.js so Camoufox always
|
||||
// uses the local donut-proxy and picks up sideloaded extensions. user.js
|
||||
// values override prefs.js on every launch, so this is always canonical.
|
||||
if let Some(proxy_str) = &config.proxy {
|
||||
let user_js_path = profile_path.join("user.js");
|
||||
let mut prefs = String::new();
|
||||
|
||||
// Preserve existing user.js content (ephemeral prefs, etc.)
|
||||
// Preserve existing user.js lines, but strip any keys we're about to
|
||||
// re-emit so they never duplicate.
|
||||
let managed_keys = [
|
||||
"network.proxy.",
|
||||
"xpinstall.signatures.required",
|
||||
"extensions.startupScanScopes",
|
||||
];
|
||||
if let Ok(existing) = std::fs::read_to_string(&user_js_path) {
|
||||
// Strip old proxy prefs so we don't duplicate
|
||||
for line in existing.lines() {
|
||||
if !line.contains("network.proxy.") {
|
||||
if !managed_keys.iter().any(|k| line.contains(k)) {
|
||||
prefs.push_str(line);
|
||||
prefs.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Required for sideloaded extensions:
|
||||
// - signatures.required=false accepts unsigned .xpi (Camoufox is built
|
||||
// without MOZ_REQUIRE_SIGNING so this is honored).
|
||||
// - startupScanScopes=1 rescans SCOPE_PROFILE on each launch so newly
|
||||
// dropped .xpi files in <profile>/extensions/ get registered.
|
||||
prefs.push_str(
|
||||
"user_pref(\"xpinstall.signatures.required\", false);\n\
|
||||
user_pref(\"extensions.startupScanScopes\", 1);\n",
|
||||
);
|
||||
|
||||
if let 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);
|
||||
@@ -707,7 +722,7 @@ impl CamoufoxManager {
|
||||
}
|
||||
|
||||
if let Err(e) = std::fs::write(&user_js_path, prefs) {
|
||||
log::warn!("Failed to write proxy prefs to user.js: {e}");
|
||||
log::warn!("Failed to write user.js: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ pub struct Extension {
|
||||
pub author: Option<String>,
|
||||
#[serde(default)]
|
||||
pub homepage_url: Option<String>,
|
||||
/// Firefox extension ID from `browser_specific_settings.gecko.id` (or
|
||||
/// `applications.gecko.id` in old manifests). Firefox refuses to load a
|
||||
/// sideloaded .xpi unless the filename matches this value.
|
||||
#[serde(default)]
|
||||
pub gecko_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -157,6 +162,32 @@ fn extract_manifest_metadata(
|
||||
(name, version, description, author, homepage_url)
|
||||
}
|
||||
|
||||
/// Read `browser_specific_settings.gecko.id` (or the legacy
|
||||
/// `applications.gecko.id`) from the extension's manifest.json. Firefox uses
|
||||
/// this value as the canonical add-on ID; sideloaded .xpi files must be named
|
||||
/// `<gecko_id>.xpi` to be picked up.
|
||||
fn extract_gecko_id(file_data: &[u8], file_type: &str) -> Option<String> {
|
||||
let zip_start = if file_type == "crx" {
|
||||
find_zip_start(file_data)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let cursor = std::io::Cursor::new(&file_data[zip_start..]);
|
||||
let mut archive = zip::ZipArchive::new(cursor).ok()?;
|
||||
let mut manifest_content = String::new();
|
||||
std::io::Read::read_to_string(
|
||||
&mut archive.by_name("manifest.json").ok()?,
|
||||
&mut manifest_content,
|
||||
)
|
||||
.ok()?;
|
||||
let manifest: serde_json::Value = serde_json::from_str(&manifest_content).ok()?;
|
||||
manifest
|
||||
.pointer("/browser_specific_settings/gecko/id")
|
||||
.or_else(|| manifest.pointer("/applications/gecko/id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn extract_icon_from_archive(file_data: &[u8], file_type: &str) -> Option<(Vec<u8>, String)> {
|
||||
let zip_start = if file_type == "crx" {
|
||||
find_zip_start(file_data)
|
||||
@@ -285,6 +316,7 @@ impl ExtensionManager {
|
||||
name
|
||||
};
|
||||
|
||||
let gecko_id = extract_gecko_id(&file_data, &file_type);
|
||||
let ext = Extension {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: final_name,
|
||||
@@ -299,6 +331,7 @@ impl ExtensionManager {
|
||||
description,
|
||||
author,
|
||||
homepage_url,
|
||||
gecko_id,
|
||||
};
|
||||
|
||||
let file_dir = self.get_file_dir(&ext.id);
|
||||
@@ -415,6 +448,7 @@ impl ExtensionManager {
|
||||
ext.name = mn;
|
||||
}
|
||||
}
|
||||
ext.gecko_id = extract_gecko_id(&data, &new_file_type);
|
||||
|
||||
if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&data, &new_file_type) {
|
||||
let icon_path = self.get_extension_dir(id).join(format!("icon.{icon_ext}"));
|
||||
@@ -893,24 +927,33 @@ impl ExtensionManager {
|
||||
continue;
|
||||
}
|
||||
let src_file = self.get_file_dir(ext_id).join(&ext.file_name);
|
||||
if src_file.exists() {
|
||||
// Firefox expects .xpi files in extensions dir
|
||||
let dest_name = if ext.file_type == "zip" {
|
||||
format!(
|
||||
"{}.xpi",
|
||||
ext
|
||||
.file_name
|
||||
.rsplit('.')
|
||||
.next_back()
|
||||
.unwrap_or(&ext.file_name)
|
||||
)
|
||||
} else {
|
||||
ext.file_name.clone()
|
||||
};
|
||||
let dest = extensions_dir.join(&dest_name);
|
||||
fs::copy(&src_file, &dest)?;
|
||||
extension_paths.push(dest.to_string_lossy().to_string());
|
||||
if !src_file.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Firefox/Camoufox only loads sideloaded .xpi files whose filename
|
||||
// matches `browser_specific_settings.gecko.id` from the manifest.
|
||||
// Prefer the cached value; fall back to reading the manifest now
|
||||
// for extensions added before the field existed.
|
||||
let gecko_id = if let Some(ref id) = ext.gecko_id {
|
||||
Some(id.clone())
|
||||
} else if let Ok(data) = fs::read(&src_file) {
|
||||
extract_gecko_id(&data, &ext.file_type)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let Some(gecko_id) = gecko_id else {
|
||||
log::warn!(
|
||||
"Skipping Firefox extension '{}': could not determine gecko id from manifest.json",
|
||||
ext.name
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let dest = extensions_dir.join(format!("{gecko_id}.xpi"));
|
||||
fs::copy(&src_file, &dest)?;
|
||||
extension_paths.push(dest.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1022,30 +1065,49 @@ impl ExtensionManager {
|
||||
}
|
||||
}
|
||||
|
||||
if ext.version.is_none() && ext.description.is_none() {
|
||||
let needs_meta_backfill = ext.version.is_none() && ext.description.is_none();
|
||||
let needs_gecko_backfill =
|
||||
ext.gecko_id.is_none() && ext.browser_compatibility.iter().any(|b| b == "firefox");
|
||||
|
||||
if needs_meta_backfill || needs_gecko_backfill {
|
||||
let file_path = file_dir.join(&ext.file_name);
|
||||
if let Ok(file_data) = fs::read(&file_path) {
|
||||
let (manifest_name, version, description, author, homepage_url) =
|
||||
extract_manifest_metadata(&file_data, &ext.file_type);
|
||||
if version.is_some()
|
||||
|| description.is_some()
|
||||
|| author.is_some()
|
||||
|| homepage_url.is_some()
|
||||
|| manifest_name.is_some()
|
||||
{
|
||||
let mut updated_ext = ext.clone();
|
||||
if let Some(v) = version {
|
||||
updated_ext.version = Some(v);
|
||||
let mut updated_ext = ext.clone();
|
||||
let mut changed = false;
|
||||
|
||||
if needs_meta_backfill {
|
||||
let (manifest_name, version, description, author, homepage_url) =
|
||||
extract_manifest_metadata(&file_data, &ext.file_type);
|
||||
if version.is_some()
|
||||
|| description.is_some()
|
||||
|| author.is_some()
|
||||
|| homepage_url.is_some()
|
||||
|| manifest_name.is_some()
|
||||
{
|
||||
if let Some(v) = version {
|
||||
updated_ext.version = Some(v);
|
||||
}
|
||||
if let Some(d) = description {
|
||||
updated_ext.description = Some(d);
|
||||
}
|
||||
if let Some(a) = author {
|
||||
updated_ext.author = Some(a);
|
||||
}
|
||||
if let Some(h) = homepage_url {
|
||||
updated_ext.homepage_url = Some(h);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if let Some(d) = description {
|
||||
updated_ext.description = Some(d);
|
||||
}
|
||||
if let Some(a) = author {
|
||||
updated_ext.author = Some(a);
|
||||
}
|
||||
if let Some(h) = homepage_url {
|
||||
updated_ext.homepage_url = Some(h);
|
||||
}
|
||||
|
||||
if needs_gecko_backfill {
|
||||
if let Some(gid) = extract_gecko_id(&file_data, &ext.file_type) {
|
||||
updated_ext.gecko_id = Some(gid);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
let metadata_path = self.get_metadata_path(&ext.id);
|
||||
if let Ok(json) = serde_json::to_string_pretty(&updated_ext) {
|
||||
let _ = fs::write(metadata_path, json);
|
||||
|
||||
+409
-1
@@ -33,6 +33,48 @@ pub struct McpTool {
|
||||
pub input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
/// JavaScript executed in the target page to enumerate visible interactive
|
||||
/// elements. Returns a JSON string `{elements, count, truncated}` where
|
||||
/// `elements` is the newline-joined labeled list. Live references are stashed
|
||||
/// on `window.__donut_interactive` so subsequent `click_by_index` /
|
||||
/// `type_by_index` calls can resolve `index → Element` without round-tripping
|
||||
/// a selector. `__MAX_CHARS__` is substituted at call time.
|
||||
const INTERACTIVE_ELEMENTS_JS: &str = r#"(() => {
|
||||
const SELECTORS = 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="tab"], [role="menuitem"], [role="combobox"], [role="option"], [contenteditable=""], [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
|
||||
const ATTRS = ['type','name','id','role','aria-label','aria-checked','aria-expanded','placeholder','title','value','href','alt'];
|
||||
const MAX_CHARS = __MAX_CHARS__;
|
||||
const interactive = [];
|
||||
const lines = [];
|
||||
let truncated = false;
|
||||
let total = 0;
|
||||
const nodes = document.querySelectorAll(SELECTORS);
|
||||
for (const el of nodes) {
|
||||
if (el.disabled) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width <= 0 || r.height <= 0) continue;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') continue;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const parts = [];
|
||||
for (const a of ATTRS) {
|
||||
const v = el.getAttribute(a);
|
||||
if (v) parts.push(a + '="' + String(v).slice(0,100).replace(/"/g,'\\"') + '"');
|
||||
}
|
||||
let text = '';
|
||||
if (!['INPUT','TEXTAREA','SELECT'].includes(el.tagName)) {
|
||||
text = (el.innerText || el.textContent || '').trim().replace(/\s+/g,' ').slice(0,100);
|
||||
}
|
||||
const idx = interactive.length;
|
||||
const line = '[' + idx + ']<' + tag + (parts.length ? ' ' + parts.join(' ') : '') + '>' + text + '</' + tag + '>';
|
||||
if (total + line.length + 1 > MAX_CHARS) { truncated = true; break; }
|
||||
total += line.length + 1;
|
||||
interactive.push(el);
|
||||
lines.push(line);
|
||||
}
|
||||
window.__donut_interactive = interactive;
|
||||
return JSON.stringify({ elements: lines.join('\n'), count: interactive.length, truncated: truncated });
|
||||
})()"#;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct McpRequest {
|
||||
@@ -1354,6 +1396,76 @@ impl McpServer {
|
||||
"required": ["profile_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "get_interactive_elements".to_string(),
|
||||
description: "Enumerate visible interactive elements on the page (buttons, links, inputs, etc.) as a compact indexed list. The returned indices are stable for the current page and can be used with click_by_index and type_by_index instead of guessing CSS selectors. Call this before click_by_index / type_by_index, and re-call after any navigation or major DOM change. Far cheaper in tokens than get_page_content for agentic browsing.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the running profile"
|
||||
},
|
||||
"max_chars": {
|
||||
"type": "integer",
|
||||
"description": "Cap on the serialized output length (default: 40000). The response carries a `truncated` flag if the list was cut off — narrow the viewport or scroll if you need elements past the cutoff."
|
||||
}
|
||||
},
|
||||
"required": ["profile_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "click_by_index".to_string(),
|
||||
description: "Click the element at the given index from the last get_interactive_elements call. Indices are valid until the next navigation. If the click triggers navigation, waits for the new page to load before returning.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the running profile"
|
||||
},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"description": "Zero-based index from the last get_interactive_elements response"
|
||||
}
|
||||
},
|
||||
"required": ["profile_id", "index"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "type_by_index".to_string(),
|
||||
description: "Focus the element at the given index from the last get_interactive_elements call and type text into it. Same human-like-typing defaults as type_text; only set instant=true when you're sure the target lacks bot detection.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the running profile"
|
||||
},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"description": "Zero-based index from the last get_interactive_elements response"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Text to type into the element"
|
||||
},
|
||||
"clear_first": {
|
||||
"type": "boolean",
|
||||
"description": "Clear the input before typing (default: true)"
|
||||
},
|
||||
"instant": {
|
||||
"type": "boolean",
|
||||
"description": "Paste all text at once instead of human typing. WARNING: only use on targets without bot detection."
|
||||
},
|
||||
"wpm": {
|
||||
"type": "number",
|
||||
"description": "Target words per minute for human typing (default: 80)"
|
||||
}
|
||||
},
|
||||
"required": ["profile_id", "index", "text"]
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1602,6 +1714,18 @@ impl McpServer {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_get_page_info(arguments).await
|
||||
}
|
||||
"get_interactive_elements" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_get_interactive_elements(arguments).await
|
||||
}
|
||||
"click_by_index" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_click_by_index(arguments).await
|
||||
}
|
||||
"type_by_index" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_type_by_index(arguments).await
|
||||
}
|
||||
_ => Err(McpError {
|
||||
code: -32602,
|
||||
message: format!("Unknown tool: {tool_name}"),
|
||||
@@ -4263,6 +4387,11 @@ impl McpServer {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("text");
|
||||
let selector = arguments.get("selector").and_then(|v| v.as_str());
|
||||
let max_chars = arguments
|
||||
.get("max_chars")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as usize)
|
||||
.unwrap_or(40_000);
|
||||
|
||||
let profile = self.get_running_profile(profile_id)?;
|
||||
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
|
||||
@@ -4310,10 +4439,28 @@ impl McpServer {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Cap output so a 500 KB DOM dump doesn't blow out the agent's context.
|
||||
// Slice on character boundaries (chars().take().collect()) rather than
|
||||
// byte indices, since the latter would panic on multi-byte boundaries.
|
||||
let total_chars = content.chars().count();
|
||||
let (text, truncated) = if total_chars > max_chars {
|
||||
(content.chars().take(max_chars).collect::<String>(), true)
|
||||
} else {
|
||||
(content.to_string(), false)
|
||||
};
|
||||
|
||||
let payload = if truncated {
|
||||
format!(
|
||||
"{text}\n\n[truncated: showing {max_chars} of {total_chars} chars — call with a larger max_chars or use get_interactive_elements for an indexed view]"
|
||||
)
|
||||
} else {
|
||||
text
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": content
|
||||
"text": payload
|
||||
}]
|
||||
}))
|
||||
}
|
||||
@@ -4361,6 +4508,267 @@ impl McpServer {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_get_interactive_elements(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing profile_id".to_string(),
|
||||
})?;
|
||||
let max_chars = arguments
|
||||
.get("max_chars")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as usize)
|
||||
.unwrap_or(40_000);
|
||||
|
||||
let profile = self.get_running_profile(profile_id)?;
|
||||
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
|
||||
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
|
||||
|
||||
// Walk the DOM for visible, non-disabled interactive elements, label them
|
||||
// with a zero-based index, and cache the live references on
|
||||
// `window.__donut_interactive` so click_by_index / type_by_index can
|
||||
// resolve the index → Element without round-tripping a selector.
|
||||
let js = INTERACTIVE_ELEMENTS_JS.replace("__MAX_CHARS__", &max_chars.to_string());
|
||||
|
||||
let result = self
|
||||
.send_cdp(
|
||||
&ws_url,
|
||||
"Runtime.evaluate",
|
||||
serde_json::json!({
|
||||
"expression": js,
|
||||
"returnByValue": true,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(exception) = result.get("exceptionDetails") {
|
||||
let msg = exception
|
||||
.get("exception")
|
||||
.and_then(|e| e.get("description"))
|
||||
.or_else(|| exception.get("text"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Enumeration failed");
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: msg.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let payload_str = result
|
||||
.get("result")
|
||||
.and_then(|r| r.get("value"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("{}");
|
||||
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_str(payload_str).unwrap_or(serde_json::json!({}));
|
||||
let elements = payload
|
||||
.get("elements")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let count = payload.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let truncated = payload
|
||||
.get("truncated")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let header = if truncated {
|
||||
format!("{count} interactive elements (truncated at {max_chars} chars — re-call with a larger max_chars or scroll the page):")
|
||||
} else {
|
||||
format!("{count} interactive elements:")
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("{header}\n{elements}")
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_click_by_index(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing profile_id".to_string(),
|
||||
})?;
|
||||
let index = arguments
|
||||
.get("index")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing index".to_string(),
|
||||
})?;
|
||||
|
||||
let profile = self.get_running_profile(profile_id)?;
|
||||
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
|
||||
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
|
||||
|
||||
let js = format!(
|
||||
r#"(() => {{
|
||||
const arr = window.__donut_interactive;
|
||||
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
|
||||
const el = arr[{index}];
|
||||
el.scrollIntoView({{block: 'center'}});
|
||||
el.click();
|
||||
return true;
|
||||
}})()"#
|
||||
);
|
||||
|
||||
let result = self
|
||||
.send_cdp_and_wait_for_load(
|
||||
&ws_url,
|
||||
"Runtime.evaluate",
|
||||
serde_json::json!({
|
||||
"expression": js,
|
||||
"returnByValue": true,
|
||||
}),
|
||||
10,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(exception) = result.get("exceptionDetails") {
|
||||
let msg = exception
|
||||
.get("exception")
|
||||
.and_then(|e| e.get("description"))
|
||||
.or_else(|| exception.get("text"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Click failed");
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: msg.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Clicked element at index {index}")
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_type_by_index(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing profile_id".to_string(),
|
||||
})?;
|
||||
let index = arguments
|
||||
.get("index")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing index".to_string(),
|
||||
})?;
|
||||
let text = arguments
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing text".to_string(),
|
||||
})?;
|
||||
let clear_first = arguments
|
||||
.get("clear_first")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
let instant = arguments
|
||||
.get("instant")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let wpm = arguments.get("wpm").and_then(|v| v.as_f64());
|
||||
|
||||
let profile = self.get_running_profile(profile_id)?;
|
||||
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
|
||||
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
|
||||
|
||||
// Mirrors handle_type_text's focus step but resolves the element via the
|
||||
// cached index instead of a CSS selector.
|
||||
let focus_js = if clear_first {
|
||||
format!(
|
||||
r#"(() => {{
|
||||
const arr = window.__donut_interactive;
|
||||
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
|
||||
const el = arr[{index}];
|
||||
el.scrollIntoView({{block: 'center'}});
|
||||
el.focus();
|
||||
el.value = '';
|
||||
el.dispatchEvent(new Event('input', {{bubbles: true}}));
|
||||
return true;
|
||||
}})()"#
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r#"(() => {{
|
||||
const arr = window.__donut_interactive;
|
||||
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
|
||||
const el = arr[{index}];
|
||||
el.scrollIntoView({{block: 'center'}});
|
||||
el.focus();
|
||||
return true;
|
||||
}})()"#
|
||||
)
|
||||
};
|
||||
|
||||
let focus_result = self
|
||||
.send_cdp(
|
||||
&ws_url,
|
||||
"Runtime.evaluate",
|
||||
serde_json::json!({
|
||||
"expression": focus_js,
|
||||
"returnByValue": true,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(exception) = focus_result.get("exceptionDetails") {
|
||||
let msg = exception
|
||||
.get("exception")
|
||||
.and_then(|e| e.get("description"))
|
||||
.or_else(|| exception.get("text"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Focus failed");
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: msg.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if instant {
|
||||
self
|
||||
.send_cdp(
|
||||
&ws_url,
|
||||
"Input.insertText",
|
||||
serde_json::json!({ "text": text }),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
self.send_human_keystrokes(&ws_url, text, wpm).await?;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Typed text into element at index {index}")
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
// --- Synchronizer handlers ---
|
||||
|
||||
async fn handle_start_sync_session(
|
||||
|
||||
@@ -1799,10 +1799,17 @@ impl ProfileManager {
|
||||
"user_pref(\"startup.homepage_welcome_url\", \"\");".to_string(),
|
||||
"user_pref(\"startup.homepage_welcome_url.additional\", \"\");".to_string(),
|
||||
"user_pref(\"startup.homepage_override_url\", \"\");".to_string(),
|
||||
// Keep extension updates enabled and allow sideloaded extensions
|
||||
// Keep extension updates enabled and allow sideloaded extensions.
|
||||
// - autoDisableScopes=0: profile-installed extensions are enabled by default.
|
||||
// - startupScanScopes=1: rescan SCOPE_PROFILE on each launch so freshly
|
||||
// dropped .xpi files in <profile>/extensions/ get registered.
|
||||
// - signatures.required=false: accept unsigned/dev .xpi files. Camoufox
|
||||
// is built without MOZ_REQUIRE_SIGNING so this is honored.
|
||||
"user_pref(\"extensions.update.enabled\", true);".to_string(),
|
||||
"user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(),
|
||||
"user_pref(\"extensions.autoDisableScopes\", 0);".to_string(),
|
||||
"user_pref(\"extensions.startupScanScopes\", 1);".to_string(),
|
||||
"user_pref(\"xpinstall.signatures.required\", false);".to_string(),
|
||||
// Completely disable browser update checking
|
||||
"user_pref(\"app.update.enabled\", false);".to_string(),
|
||||
"user_pref(\"app.update.auto\", false);".to_string(),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { AccountPage } from "@/components/account-page";
|
||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
||||
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
||||
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
|
||||
@@ -34,6 +35,7 @@ import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
|
||||
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
|
||||
import { type AppPage, RailNav } from "@/components/rail-nav";
|
||||
import { SettingsDialog } from "@/components/settings-dialog";
|
||||
import { ShortcutsPage } from "@/components/shortcuts-page";
|
||||
import { SyncAllDialog } from "@/components/sync-all-dialog";
|
||||
import { SyncConfigDialog } from "@/components/sync-config-dialog";
|
||||
import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
|
||||
@@ -53,6 +55,12 @@ import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import {
|
||||
matchesGroupDigit,
|
||||
matchesShortcut,
|
||||
SHORTCUTS,
|
||||
type ShortcutId,
|
||||
} from "@/lib/shortcuts";
|
||||
import {
|
||||
dismissToast,
|
||||
showErrorToast,
|
||||
@@ -149,6 +157,11 @@ export default function Home() {
|
||||
const [proxyManagementInitialTab, setProxyManagementInitialTab] = useState<
|
||||
"proxies" | "vpns"
|
||||
>("proxies");
|
||||
const [extensionManagementInitialTab, setExtensionManagementInitialTab] =
|
||||
useState<"extensions" | "groups">("extensions");
|
||||
const [integrationsInitialTab, setIntegrationsInitialTab] = useState<
|
||||
"api" | "mcp"
|
||||
>("api");
|
||||
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
|
||||
@@ -221,6 +234,11 @@ export default function Home() {
|
||||
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
|
||||
const [currentProfileForSync, setCurrentProfileForSync] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
// Owned by page.tsx so the command palette can request opening the profile
|
||||
// info dialog. ProfilesDataTable consumes it through controlled props.
|
||||
const [profileInfoDialog, setProfileInfoDialog] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
||||
usePermissions();
|
||||
|
||||
@@ -273,9 +291,134 @@ export default function Home() {
|
||||
case "account":
|
||||
setAccountDialogOpen(true);
|
||||
break;
|
||||
case "shortcuts":
|
||||
// Plain page render — nothing else to open.
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runShortcut = useCallback(
|
||||
(id: ShortcutId) => {
|
||||
switch (id) {
|
||||
case "openPalette":
|
||||
setCommandPaletteOpen(true);
|
||||
break;
|
||||
case "openShortcuts":
|
||||
handleRailNavigate("shortcuts");
|
||||
break;
|
||||
case "importProfile":
|
||||
handleRailNavigate("import");
|
||||
break;
|
||||
case "goProfiles":
|
||||
handleRailNavigate("profiles");
|
||||
break;
|
||||
case "goProxies": {
|
||||
// Mod+N: navigate first time; flip proxies↔vpns on subsequent presses.
|
||||
// handleRailNavigate("proxies"|"vpns") already updates the dialog's
|
||||
// initialTab, so we just pick the right destination.
|
||||
if (currentPage === "proxies") {
|
||||
handleRailNavigate("vpns");
|
||||
} else if (currentPage === "vpns") {
|
||||
handleRailNavigate("proxies");
|
||||
} else {
|
||||
handleRailNavigate(
|
||||
proxyManagementInitialTab === "vpns" ? "vpns" : "proxies",
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "goExtensions": {
|
||||
// Mod+E: flip extensions↔groups tab inside the dialog when already there.
|
||||
if (currentPage === "extensions") {
|
||||
setExtensionManagementInitialTab((cur) =>
|
||||
cur === "extensions" ? "groups" : "extensions",
|
||||
);
|
||||
} else {
|
||||
handleRailNavigate("extensions");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "goGroups":
|
||||
handleRailNavigate("groups");
|
||||
break;
|
||||
case "goIntegrations": {
|
||||
// Mod+I: flip api↔mcp tab when already on integrations.
|
||||
if (currentPage === "integrations") {
|
||||
setIntegrationsInitialTab((cur) => (cur === "api" ? "mcp" : "api"));
|
||||
} else {
|
||||
handleRailNavigate("integrations");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "goAccount":
|
||||
handleRailNavigate("account");
|
||||
break;
|
||||
case "goSettings":
|
||||
handleRailNavigate("settings");
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleRailNavigate, currentPage, proxyManagementInitialTab],
|
||||
);
|
||||
|
||||
// Ordered list the digit shortcuts and palette consume. "__all__" is index 1
|
||||
// so Mod+1 always lands on the unfiltered view; the user's groups follow.
|
||||
const orderedGroupTargets = useMemo(
|
||||
() => [
|
||||
{ id: "__all__", name: t("rail.profiles") },
|
||||
...groupsData.map((g) => ({ id: g.id, name: g.name })),
|
||||
],
|
||||
[groupsData, t],
|
||||
);
|
||||
|
||||
const selectGroupByDigit = useCallback(
|
||||
(digit: number) => {
|
||||
const target = orderedGroupTargets[digit - 1];
|
||||
if (!target) return;
|
||||
handleRailNavigate("profiles");
|
||||
handleSelectGroup(target.id);
|
||||
},
|
||||
[orderedGroupTargets, handleRailNavigate, handleSelectGroup],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Global keydown — handles Mod+1..9 group jumps first, then falls back to
|
||||
// the static SHORTCUTS table. Skipped while typing in an input, EXCEPT
|
||||
// ⌘K and ⌘/ which are meta-level shortcuts and should always be reachable.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const tag = target?.tagName;
|
||||
const isTyping =
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
tag === "SELECT" ||
|
||||
target?.isContentEditable === true;
|
||||
|
||||
const digit = matchesGroupDigit(e);
|
||||
if (digit !== null) {
|
||||
if (isTyping) return;
|
||||
if (digit - 1 >= orderedGroupTargets.length) return;
|
||||
e.preventDefault();
|
||||
selectGroupByDigit(digit);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const s of SHORTCUTS) {
|
||||
if (!matchesShortcut(s, e)) continue;
|
||||
if (isTyping && s.id !== "openPalette" && s.id !== "openShortcuts") {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
runShortcut(s.id);
|
||||
return;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [runShortcut, selectGroupByDigit, orderedGroupTargets.length]);
|
||||
|
||||
// Check for missing binaries and offer to download them
|
||||
const checkMissingBinaries = useCallback(async () => {
|
||||
try {
|
||||
@@ -1306,6 +1449,8 @@ export default function Home() {
|
||||
{isLoading && groupsData.length === 0 ? null : null}
|
||||
<ProfilesDataTable
|
||||
profiles={filteredProfiles}
|
||||
infoDialogProfile={profileInfoDialog}
|
||||
onInfoDialogProfileChange={setProfileInfoDialog}
|
||||
onLaunchProfile={launchProfile}
|
||||
onKillProfile={handleKillProfile}
|
||||
onCloneProfile={handleCloneProfile}
|
||||
@@ -1344,6 +1489,10 @@ export default function Home() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPage === "shortcuts" && (
|
||||
<ShortcutsPage groupTargets={orderedGroupTargets} />
|
||||
)}
|
||||
|
||||
{settingsDialogOpen && (
|
||||
<SettingsDialog
|
||||
isOpen={settingsDialogOpen}
|
||||
@@ -1368,6 +1517,7 @@ export default function Home() {
|
||||
setCurrentPage("profiles");
|
||||
}}
|
||||
subPage={currentPage === "integrations"}
|
||||
initialTab={integrationsInitialTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1404,6 +1554,7 @@ export default function Home() {
|
||||
}}
|
||||
limitedMode={false}
|
||||
subPage={currentPage === "extensions"}
|
||||
initialTab={extensionManagementInitialTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1447,6 +1598,29 @@ export default function Home() {
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
|
||||
<CommandPalette
|
||||
open={commandPaletteOpen}
|
||||
onOpenChange={setCommandPaletteOpen}
|
||||
onAction={runShortcut}
|
||||
groupTargets={orderedGroupTargets}
|
||||
onSelectGroup={(id) => {
|
||||
handleRailNavigate("profiles");
|
||||
handleSelectGroup(id);
|
||||
}}
|
||||
profiles={profiles}
|
||||
runningProfileIds={runningProfiles}
|
||||
onLaunchProfile={(profile) => {
|
||||
void launchProfile(profile);
|
||||
}}
|
||||
onKillProfile={(profile) => {
|
||||
void handleKillProfile(profile);
|
||||
}}
|
||||
onShowProfileInfo={(profile) => {
|
||||
handleRailNavigate("profiles");
|
||||
setProfileInfoDialog(profile);
|
||||
}}
|
||||
/>
|
||||
|
||||
{pendingUrls.map((pendingUrl) => (
|
||||
<ProfileSelectorDialog
|
||||
key={pendingUrl.id}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear } from "react-icons/go";
|
||||
import {
|
||||
LuCircleStop,
|
||||
LuCloud,
|
||||
LuInfo,
|
||||
LuKeyboard,
|
||||
LuPlay,
|
||||
LuPlug,
|
||||
LuPuzzle,
|
||||
LuUser,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
formatGroupShortcut,
|
||||
formatShortcut,
|
||||
SHORTCUTS,
|
||||
type ShortcutDef,
|
||||
type ShortcutId,
|
||||
} from "@/lib/shortcuts";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
|
||||
interface GroupTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAction: (id: ShortcutId) => void;
|
||||
/** Ordered list of groups for Mod+1..9. Index 0 is the catch-all entry. */
|
||||
groupTargets: GroupTarget[];
|
||||
onSelectGroup: (id: string) => void;
|
||||
/** All profiles for launch/stop/info entries. */
|
||||
profiles: BrowserProfile[];
|
||||
runningProfileIds: Set<string>;
|
||||
onLaunchProfile: (profile: BrowserProfile) => void;
|
||||
onKillProfile: (profile: BrowserProfile) => void;
|
||||
onShowProfileInfo: (profile: BrowserProfile) => void;
|
||||
}
|
||||
|
||||
const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
|
||||
openPalette: LuKeyboard,
|
||||
openShortcuts: LuKeyboard,
|
||||
importProfile: FaDownload,
|
||||
goProfiles: LuUser,
|
||||
goProxies: FiWifi,
|
||||
goExtensions: LuPuzzle,
|
||||
goGroups: LuUsers,
|
||||
goIntegrations: LuPlug,
|
||||
goAccount: LuCloud,
|
||||
goSettings: GoGear,
|
||||
};
|
||||
|
||||
function Tokens({ tokens }: { tokens: string[] }) {
|
||||
return (
|
||||
<CommandShortcut className="flex items-center gap-0.5">
|
||||
{tokens.map((tok, i) => (
|
||||
<kbd
|
||||
key={i}
|
||||
className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded border border-border bg-muted text-[10px] font-medium text-muted-foreground"
|
||||
>
|
||||
{tok}
|
||||
</kbd>
|
||||
))}
|
||||
</CommandShortcut>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutTokens({ shortcut }: { shortcut: ShortcutDef }) {
|
||||
return <Tokens tokens={formatShortcut(shortcut)} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token-AND fuzzy filter. Every whitespace-separated token in the query has
|
||||
* to appear as a substring somewhere in the item's value or its keywords; the
|
||||
* score is reduced when tokens appear later in the haystack so a closer match
|
||||
* sorts higher. "ctest info" matches "Info — ctest" — the default cmdk filter
|
||||
* requires tokens in document order so it would otherwise return zero.
|
||||
*/
|
||||
function fuzzyFilter(
|
||||
value: string,
|
||||
search: string,
|
||||
keywords?: string[],
|
||||
): number {
|
||||
if (!search.trim()) return 1;
|
||||
const haystack = [value, ...(keywords ?? [])].join(" ").toLowerCase();
|
||||
const tokens = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
let score = 0;
|
||||
for (const tok of tokens) {
|
||||
const idx = haystack.indexOf(tok);
|
||||
if (idx === -1) return 0;
|
||||
score += 1 / (1 + idx);
|
||||
}
|
||||
return score / tokens.length;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAction,
|
||||
groupTargets,
|
||||
onSelectGroup,
|
||||
profiles,
|
||||
runningProfileIds,
|
||||
onLaunchProfile,
|
||||
onKillProfile,
|
||||
onShowProfileInfo,
|
||||
}: CommandPaletteProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// `cmdk` calls onSelect BEFORE the dialog closes. Close first, then dispatch
|
||||
// on the next tick so an action that opens another dialog doesn't race
|
||||
// this one's close animation.
|
||||
const dispatch = (fn: () => void) => {
|
||||
onOpenChange(false);
|
||||
setTimeout(fn, 0);
|
||||
};
|
||||
|
||||
const byGroup = (group: ShortcutDef["group"]) =>
|
||||
SHORTCUTS.filter((s) => s.group === group);
|
||||
|
||||
// Limit to 9 — only the first 9 group targets have a Mod+digit binding.
|
||||
// We still display more in the palette (without a shortcut hint) so the
|
||||
// user can search/jump to any of them.
|
||||
const renderGroup = (target: GroupTarget, index: number) => (
|
||||
<CommandItem
|
||||
key={target.id}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onSelectGroup(target.id);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LuUsers />
|
||||
<span>{target.name}</span>
|
||||
{index < 9 ? <Tokens tokens={formatGroupShortcut(index + 1)} /> : null}
|
||||
</CommandItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange} filter={fuzzyFilter}>
|
||||
<CommandInput placeholder={t("commandPalette.placeholder")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("commandPalette.empty")}</CommandEmpty>
|
||||
|
||||
<CommandGroup heading={t("commandPalette.groups.navigation")}>
|
||||
{byGroup("navigation").map((s) => {
|
||||
const Icon = ICONS[s.id];
|
||||
return (
|
||||
<CommandItem
|
||||
key={s.id}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onAction(s.id);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
<span>{t(s.labelKey)}</span>
|
||||
<ShortcutTokens shortcut={s} />
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
|
||||
{groupTargets.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading={t("commandPalette.groups.profileGroups")}>
|
||||
{groupTargets.map((target, i) => renderGroup(target, i))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{profiles.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading={t("commandPalette.groups.profiles")}>
|
||||
{profiles.map((p) => {
|
||||
const running = runningProfileIds.has(p.id);
|
||||
return running ? (
|
||||
<CommandItem
|
||||
key={`run-${p.id}`}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onKillProfile(p);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LuCircleStop />
|
||||
<span>
|
||||
{t("commandPalette.actions.stopProfile", {
|
||||
name: p.name,
|
||||
})}
|
||||
</span>
|
||||
</CommandItem>
|
||||
) : (
|
||||
<CommandItem
|
||||
key={`run-${p.id}`}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onLaunchProfile(p);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LuPlay />
|
||||
<span>
|
||||
{t("commandPalette.actions.launchProfile", {
|
||||
name: p.name,
|
||||
})}
|
||||
</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
{profiles.map((p) => (
|
||||
<CommandItem
|
||||
key={`info-${p.id}`}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onShowProfileInfo(p);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LuInfo />
|
||||
<span>
|
||||
{t("commandPalette.actions.profileInfo", { name: p.name })}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading={t("commandPalette.groups.actions")}>
|
||||
{byGroup("actions").map((s) => {
|
||||
const Icon = ICONS[s.id];
|
||||
return (
|
||||
<CommandItem
|
||||
key={s.id}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onAction(s.id);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
<span>{t(s.labelKey)}</span>
|
||||
<ShortcutTokens shortcut={s} />
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
@@ -130,6 +130,8 @@ interface ExtensionManagementDialogProps {
|
||||
onClose: () => void;
|
||||
limitedMode: boolean;
|
||||
subPage?: boolean;
|
||||
/** Which tab is displayed when the dialog mounts; defaults to "extensions". */
|
||||
initialTab?: "extensions" | "groups";
|
||||
}
|
||||
|
||||
export function ExtensionManagementDialog({
|
||||
@@ -137,6 +139,7 @@ export function ExtensionManagementDialog({
|
||||
onClose,
|
||||
limitedMode,
|
||||
subPage,
|
||||
initialTab = "extensions",
|
||||
}: ExtensionManagementDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||
@@ -208,9 +211,10 @@ export function ExtensionManagementDialog({
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
// Tab
|
||||
// Tab — keyed off `initialTab` so remounting the dialog with a new initial
|
||||
// tab (e.g. via the Mod+E shortcut toggle) jumps to that tab.
|
||||
const [activeTab, setActiveTab] = useState<"extensions" | "groups">(
|
||||
"extensions",
|
||||
initialTab,
|
||||
);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -1120,6 +1124,7 @@ export function ExtensionManagementDialog({
|
||||
)}
|
||||
|
||||
<AnimatedTabs
|
||||
key={initialTab}
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "extensions" | "groups")}
|
||||
className="flex-1 min-h-0 flex flex-col"
|
||||
|
||||
@@ -62,6 +62,8 @@ interface IntegrationsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
subPage?: boolean;
|
||||
/** Which tab is displayed when the dialog mounts; defaults to "api". */
|
||||
initialTab?: "api" | "mcp";
|
||||
}
|
||||
|
||||
function AgentIcon({ category }: { category: AgentCategory }) {
|
||||
@@ -98,6 +100,7 @@ export function IntegrationsDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
subPage,
|
||||
initialTab = "api",
|
||||
}: IntegrationsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [settings, setSettings] = useState<AppSettings>({
|
||||
@@ -310,7 +313,7 @@ export function IntegrationsDialog({
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto flex-1 min-h-0">
|
||||
<AnimatedTabs defaultValue="api">
|
||||
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="api">
|
||||
{t("integrations.tabApi")}
|
||||
|
||||
@@ -1052,6 +1052,13 @@ interface ProfilesDataTableProps {
|
||||
onSetPassword?: (profile: BrowserProfile) => void;
|
||||
onChangePassword?: (profile: BrowserProfile) => void;
|
||||
onRemovePassword?: (profile: BrowserProfile) => void;
|
||||
/**
|
||||
* When provided, the info dialog is controlled by the parent. Allows the
|
||||
* command palette in page.tsx to open the dialog directly without lifting
|
||||
* every other piece of internal table state.
|
||||
*/
|
||||
infoDialogProfile?: BrowserProfile | null;
|
||||
onInfoDialogProfileChange?: (profile: BrowserProfile | null) => void;
|
||||
}
|
||||
|
||||
export function ProfilesDataTable({
|
||||
@@ -1084,6 +1091,8 @@ export function ProfilesDataTable({
|
||||
onSetPassword,
|
||||
onChangePassword,
|
||||
onRemovePassword,
|
||||
infoDialogProfile,
|
||||
onInfoDialogProfileChange,
|
||||
}: ProfilesDataTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
||||
@@ -1155,8 +1164,22 @@ export function ProfilesDataTable({
|
||||
const [profileToDelete, setProfileToDelete] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const [profileForInfoDialog, setProfileForInfoDialog] =
|
||||
const [internalInfoDialogProfile, setInternalInfoDialogProfile] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const isInfoDialogControlled = onInfoDialogProfileChange !== undefined;
|
||||
const profileForInfoDialog = isInfoDialogControlled
|
||||
? (infoDialogProfile ?? null)
|
||||
: internalInfoDialogProfile;
|
||||
const setProfileForInfoDialog = React.useCallback(
|
||||
(p: BrowserProfile | null) => {
|
||||
if (isInfoDialogControlled) {
|
||||
onInfoDialogProfileChange?.(p);
|
||||
} else {
|
||||
setInternalInfoDialogProfile(p);
|
||||
}
|
||||
},
|
||||
[isInfoDialogControlled, onInfoDialogProfileChange],
|
||||
);
|
||||
const [bypassRulesProfile, setBypassRulesProfile] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
|
||||
@@ -2836,7 +2859,7 @@ export function ProfilesDataTable({
|
||||
},
|
||||
},
|
||||
],
|
||||
[t],
|
||||
[t, setProfileForInfoDialog],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
|
||||
@@ -5,7 +5,14 @@ import { useTranslation } from "react-i18next";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal } from "react-icons/go";
|
||||
import { LuCloud, LuPlug, LuPuzzle, LuUser, LuUsers } from "react-icons/lu";
|
||||
import {
|
||||
LuCloud,
|
||||
LuKeyboard,
|
||||
LuPlug,
|
||||
LuPuzzle,
|
||||
LuUser,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "./icons/logo";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
@@ -19,7 +26,8 @@ export type AppPage =
|
||||
| "settings"
|
||||
| "integrations"
|
||||
| "account"
|
||||
| "import";
|
||||
| "import"
|
||||
| "shortcuts";
|
||||
|
||||
const CLICK_THRESHOLD = 5;
|
||||
const CLICK_WINDOW_MS = 2000;
|
||||
@@ -257,6 +265,12 @@ const MORE_ITEMS: MoreMenuItem[] = [
|
||||
labelKey: "rail.more.importProfile",
|
||||
hintKey: "rail.more.importProfileHint",
|
||||
},
|
||||
{
|
||||
page: "shortcuts",
|
||||
Icon: LuKeyboard,
|
||||
labelKey: "rail.more.keyboardShortcuts",
|
||||
hintKey: "rail.more.keyboardShortcutsHint",
|
||||
},
|
||||
];
|
||||
|
||||
export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -34,10 +34,14 @@ function CommandDialog({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
filter,
|
||||
shouldFilter,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
filter?: React.ComponentProps<typeof CommandPrimitive>["filter"];
|
||||
shouldFilter?: React.ComponentProps<typeof CommandPrimitive>["shouldFilter"];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTitle = title ?? t("common.commandPalette.title");
|
||||
@@ -50,7 +54,11 @@ function CommandDialog({
|
||||
<DialogDescription>{resolvedDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
<Command
|
||||
filter={filter}
|
||||
shouldFilter={shouldFilter}
|
||||
className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||
>
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1803,7 +1803,9 @@
|
||||
"label": "More",
|
||||
"closeAriaLabel": "Close menu",
|
||||
"importProfile": "Import profile",
|
||||
"importProfileHint": "Bring profiles from another tool"
|
||||
"importProfileHint": "Bring profiles from another tool",
|
||||
"keyboardShortcuts": "Keyboard shortcuts",
|
||||
"keyboardShortcutsHint": "View all shortcuts"
|
||||
},
|
||||
"network": "Network",
|
||||
"integrations": "Integrations",
|
||||
@@ -1817,7 +1819,8 @@
|
||||
"settings": "Settings",
|
||||
"integrations": "Integrations",
|
||||
"account": "Account",
|
||||
"import": "Import profile"
|
||||
"import": "Import profile",
|
||||
"shortcuts": "Keyboard shortcuts"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
@@ -1870,5 +1873,36 @@
|
||||
"testConnection": "Test connection",
|
||||
"disconnect": "Disconnect"
|
||||
}
|
||||
},
|
||||
"shortcutsPage": {
|
||||
"title": "Keyboard shortcuts",
|
||||
"description": "Speed up your workflow with these shortcuts."
|
||||
},
|
||||
"commandPalette": {
|
||||
"placeholder": "Type a command or search...",
|
||||
"empty": "No results found.",
|
||||
"groups": {
|
||||
"navigation": "Navigation",
|
||||
"profiles": "Profiles",
|
||||
"actions": "Actions",
|
||||
"profileGroups": "Profile groups"
|
||||
},
|
||||
"actions": {
|
||||
"launchProfile": "Launch {{name}}",
|
||||
"stopProfile": "Stop {{name}}",
|
||||
"profileInfo": "Info — {{name}}"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"openPalette": "Open command palette",
|
||||
"openShortcuts": "View keyboard shortcuts",
|
||||
"importProfile": "Import profile",
|
||||
"goProfiles": "Go to Profiles",
|
||||
"goProxies": "Go to Network",
|
||||
"goExtensions": "Go to Extensions",
|
||||
"goGroups": "Go to Groups",
|
||||
"goIntegrations": "Go to Integrations",
|
||||
"goAccount": "Go to Account",
|
||||
"goSettings": "Go to Settings"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1803,7 +1803,9 @@
|
||||
"label": "Más",
|
||||
"closeAriaLabel": "Cerrar menú",
|
||||
"importProfile": "Importar perfil",
|
||||
"importProfileHint": "Trae perfiles de otra herramienta"
|
||||
"importProfileHint": "Trae perfiles de otra herramienta",
|
||||
"keyboardShortcuts": "Atajos de teclado",
|
||||
"keyboardShortcutsHint": "Ver todos los atajos"
|
||||
},
|
||||
"network": "Red",
|
||||
"integrations": "Integraciones",
|
||||
@@ -1817,7 +1819,8 @@
|
||||
"settings": "Ajustes",
|
||||
"integrations": "Integraciones",
|
||||
"account": "Cuenta",
|
||||
"import": "Importar perfil"
|
||||
"import": "Importar perfil",
|
||||
"shortcuts": "Atajos de teclado"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
@@ -1870,5 +1873,36 @@
|
||||
"testConnection": "Probar conexión",
|
||||
"disconnect": "Desconectar"
|
||||
}
|
||||
},
|
||||
"shortcutsPage": {
|
||||
"title": "Atajos de teclado",
|
||||
"description": "Agiliza tu flujo de trabajo con estos atajos."
|
||||
},
|
||||
"commandPalette": {
|
||||
"placeholder": "Escribe un comando o busca...",
|
||||
"empty": "No se encontraron resultados.",
|
||||
"groups": {
|
||||
"navigation": "Navegación",
|
||||
"profiles": "Perfiles",
|
||||
"actions": "Acciones",
|
||||
"profileGroups": "Grupos de perfiles"
|
||||
},
|
||||
"actions": {
|
||||
"launchProfile": "Iniciar {{name}}",
|
||||
"stopProfile": "Detener {{name}}",
|
||||
"profileInfo": "Información — {{name}}"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"openPalette": "Abrir paleta de comandos",
|
||||
"openShortcuts": "Ver atajos de teclado",
|
||||
"importProfile": "Importar perfil",
|
||||
"goProfiles": "Ir a Perfiles",
|
||||
"goProxies": "Ir a Red",
|
||||
"goExtensions": "Ir a Extensiones",
|
||||
"goGroups": "Ir a Grupos",
|
||||
"goIntegrations": "Ir a Integraciones",
|
||||
"goAccount": "Ir a Cuenta",
|
||||
"goSettings": "Ir a Configuración"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1803,7 +1803,9 @@
|
||||
"label": "Plus",
|
||||
"closeAriaLabel": "Fermer le menu",
|
||||
"importProfile": "Importer un profil",
|
||||
"importProfileHint": "Importer depuis un autre outil"
|
||||
"importProfileHint": "Importer depuis un autre outil",
|
||||
"keyboardShortcuts": "Raccourcis clavier",
|
||||
"keyboardShortcutsHint": "Voir tous les raccourcis"
|
||||
},
|
||||
"network": "Réseau",
|
||||
"integrations": "Intégrations",
|
||||
@@ -1817,7 +1819,8 @@
|
||||
"settings": "Paramètres",
|
||||
"integrations": "Intégrations",
|
||||
"account": "Compte",
|
||||
"import": "Importer un profil"
|
||||
"import": "Importer un profil",
|
||||
"shortcuts": "Raccourcis clavier"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
@@ -1870,5 +1873,36 @@
|
||||
"testConnection": "Tester la connexion",
|
||||
"disconnect": "Déconnecter"
|
||||
}
|
||||
},
|
||||
"shortcutsPage": {
|
||||
"title": "Raccourcis clavier",
|
||||
"description": "Accélérez votre flux de travail avec ces raccourcis."
|
||||
},
|
||||
"commandPalette": {
|
||||
"placeholder": "Tapez une commande ou recherchez...",
|
||||
"empty": "Aucun résultat trouvé.",
|
||||
"groups": {
|
||||
"navigation": "Navigation",
|
||||
"profiles": "Profils",
|
||||
"actions": "Actions",
|
||||
"profileGroups": "Groupes de profils"
|
||||
},
|
||||
"actions": {
|
||||
"launchProfile": "Lancer {{name}}",
|
||||
"stopProfile": "Arrêter {{name}}",
|
||||
"profileInfo": "Informations — {{name}}"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"openPalette": "Ouvrir la palette de commandes",
|
||||
"openShortcuts": "Voir les raccourcis clavier",
|
||||
"importProfile": "Importer un profil",
|
||||
"goProfiles": "Aller à Profils",
|
||||
"goProxies": "Aller à Réseau",
|
||||
"goExtensions": "Aller à Extensions",
|
||||
"goGroups": "Aller à Groupes",
|
||||
"goIntegrations": "Aller à Intégrations",
|
||||
"goAccount": "Aller à Compte",
|
||||
"goSettings": "Aller à Paramètres"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1803,7 +1803,9 @@
|
||||
"label": "その他",
|
||||
"closeAriaLabel": "メニューを閉じる",
|
||||
"importProfile": "プロファイルをインポート",
|
||||
"importProfileHint": "別のツールから取り込む"
|
||||
"importProfileHint": "別のツールから取り込む",
|
||||
"keyboardShortcuts": "キーボードショートカット",
|
||||
"keyboardShortcutsHint": "すべてのショートカットを表示"
|
||||
},
|
||||
"network": "ネットワーク",
|
||||
"integrations": "連携",
|
||||
@@ -1817,7 +1819,8 @@
|
||||
"settings": "設定",
|
||||
"integrations": "連携",
|
||||
"account": "アカウント",
|
||||
"import": "プロファイルをインポート"
|
||||
"import": "プロファイルをインポート",
|
||||
"shortcuts": "キーボードショートカット"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
@@ -1870,5 +1873,36 @@
|
||||
"testConnection": "接続をテスト",
|
||||
"disconnect": "切断"
|
||||
}
|
||||
},
|
||||
"shortcutsPage": {
|
||||
"title": "キーボードショートカット",
|
||||
"description": "これらのショートカットでワークフローを高速化できます。"
|
||||
},
|
||||
"commandPalette": {
|
||||
"placeholder": "コマンドを入力するか検索...",
|
||||
"empty": "結果が見つかりませんでした。",
|
||||
"groups": {
|
||||
"navigation": "ナビゲーション",
|
||||
"profiles": "プロファイル",
|
||||
"actions": "アクション",
|
||||
"profileGroups": "プロファイルグループ"
|
||||
},
|
||||
"actions": {
|
||||
"launchProfile": "{{name}} を起動",
|
||||
"stopProfile": "{{name}} を停止",
|
||||
"profileInfo": "情報 — {{name}}"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"openPalette": "コマンドパレットを開く",
|
||||
"openShortcuts": "キーボードショートカットを表示",
|
||||
"importProfile": "プロファイルをインポート",
|
||||
"goProfiles": "プロファイルへ移動",
|
||||
"goProxies": "ネットワークへ移動",
|
||||
"goExtensions": "拡張機能へ移動",
|
||||
"goGroups": "グループへ移動",
|
||||
"goIntegrations": "統合へ移動",
|
||||
"goAccount": "アカウントへ移動",
|
||||
"goSettings": "設定へ移動"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1803,7 +1803,9 @@
|
||||
"label": "Mais",
|
||||
"closeAriaLabel": "Fechar menu",
|
||||
"importProfile": "Importar perfil",
|
||||
"importProfileHint": "Trazer perfis de outra ferramenta"
|
||||
"importProfileHint": "Trazer perfis de outra ferramenta",
|
||||
"keyboardShortcuts": "Atalhos de teclado",
|
||||
"keyboardShortcutsHint": "Ver todos os atalhos"
|
||||
},
|
||||
"network": "Rede",
|
||||
"integrations": "Integrações",
|
||||
@@ -1817,7 +1819,8 @@
|
||||
"settings": "Configurações",
|
||||
"integrations": "Integrações",
|
||||
"account": "Conta",
|
||||
"import": "Importar perfil"
|
||||
"import": "Importar perfil",
|
||||
"shortcuts": "Atalhos de teclado"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
@@ -1870,5 +1873,36 @@
|
||||
"testConnection": "Testar conexão",
|
||||
"disconnect": "Desconectar"
|
||||
}
|
||||
},
|
||||
"shortcutsPage": {
|
||||
"title": "Atalhos de teclado",
|
||||
"description": "Acelere seu fluxo de trabalho com estes atalhos."
|
||||
},
|
||||
"commandPalette": {
|
||||
"placeholder": "Digite um comando ou pesquise...",
|
||||
"empty": "Nenhum resultado encontrado.",
|
||||
"groups": {
|
||||
"navigation": "Navegação",
|
||||
"profiles": "Perfis",
|
||||
"actions": "Ações",
|
||||
"profileGroups": "Grupos de perfis"
|
||||
},
|
||||
"actions": {
|
||||
"launchProfile": "Iniciar {{name}}",
|
||||
"stopProfile": "Parar {{name}}",
|
||||
"profileInfo": "Informações — {{name}}"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"openPalette": "Abrir paleta de comandos",
|
||||
"openShortcuts": "Ver atalhos de teclado",
|
||||
"importProfile": "Importar perfil",
|
||||
"goProfiles": "Ir para Perfis",
|
||||
"goProxies": "Ir para Rede",
|
||||
"goExtensions": "Ir para Extensões",
|
||||
"goGroups": "Ir para Grupos",
|
||||
"goIntegrations": "Ir para Integrações",
|
||||
"goAccount": "Ir para Conta",
|
||||
"goSettings": "Ir para Configurações"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1803,7 +1803,9 @@
|
||||
"label": "Ещё",
|
||||
"closeAriaLabel": "Закрыть меню",
|
||||
"importProfile": "Импорт профиля",
|
||||
"importProfileHint": "Перенести профили из другого инструмента"
|
||||
"importProfileHint": "Перенести профили из другого инструмента",
|
||||
"keyboardShortcuts": "Сочетания клавиш",
|
||||
"keyboardShortcutsHint": "Показать все сочетания"
|
||||
},
|
||||
"network": "Сеть",
|
||||
"integrations": "Интеграции",
|
||||
@@ -1817,7 +1819,8 @@
|
||||
"settings": "Настройки",
|
||||
"integrations": "Интеграции",
|
||||
"account": "Аккаунт",
|
||||
"import": "Импорт профиля"
|
||||
"import": "Импорт профиля",
|
||||
"shortcuts": "Сочетания клавиш"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
@@ -1870,5 +1873,36 @@
|
||||
"testConnection": "Проверить соединение",
|
||||
"disconnect": "Отключить"
|
||||
}
|
||||
},
|
||||
"shortcutsPage": {
|
||||
"title": "Сочетания клавиш",
|
||||
"description": "Ускорьте работу с помощью этих сочетаний клавиш."
|
||||
},
|
||||
"commandPalette": {
|
||||
"placeholder": "Введите команду или поиск...",
|
||||
"empty": "Ничего не найдено.",
|
||||
"groups": {
|
||||
"navigation": "Навигация",
|
||||
"profiles": "Профили",
|
||||
"actions": "Действия",
|
||||
"profileGroups": "Группы профилей"
|
||||
},
|
||||
"actions": {
|
||||
"launchProfile": "Запустить {{name}}",
|
||||
"stopProfile": "Остановить {{name}}",
|
||||
"profileInfo": "Информация — {{name}}"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"openPalette": "Открыть командную палитру",
|
||||
"openShortcuts": "Показать сочетания клавиш",
|
||||
"importProfile": "Импортировать профиль",
|
||||
"goProfiles": "Перейти к Профилям",
|
||||
"goProxies": "Перейти к Сети",
|
||||
"goExtensions": "Перейти к Расширениям",
|
||||
"goGroups": "Перейти к Группам",
|
||||
"goIntegrations": "Перейти к Интеграциям",
|
||||
"goAccount": "Перейти к Аккаунту",
|
||||
"goSettings": "Перейти к Настройкам"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1803,7 +1803,9 @@
|
||||
"label": "更多",
|
||||
"closeAriaLabel": "关闭菜单",
|
||||
"importProfile": "导入配置文件",
|
||||
"importProfileHint": "从其他工具导入"
|
||||
"importProfileHint": "从其他工具导入",
|
||||
"keyboardShortcuts": "键盘快捷键",
|
||||
"keyboardShortcutsHint": "查看所有快捷键"
|
||||
},
|
||||
"network": "网络",
|
||||
"integrations": "集成",
|
||||
@@ -1817,7 +1819,8 @@
|
||||
"settings": "设置",
|
||||
"integrations": "集成",
|
||||
"account": "账户",
|
||||
"import": "导入配置文件"
|
||||
"import": "导入配置文件",
|
||||
"shortcuts": "键盘快捷键"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
@@ -1870,5 +1873,36 @@
|
||||
"testConnection": "测试连接",
|
||||
"disconnect": "断开连接"
|
||||
}
|
||||
},
|
||||
"shortcutsPage": {
|
||||
"title": "键盘快捷键",
|
||||
"description": "使用这些快捷键加速您的工作流程。"
|
||||
},
|
||||
"commandPalette": {
|
||||
"placeholder": "输入命令或搜索...",
|
||||
"empty": "未找到结果。",
|
||||
"groups": {
|
||||
"navigation": "导航",
|
||||
"profiles": "配置文件",
|
||||
"actions": "操作",
|
||||
"profileGroups": "配置文件分组"
|
||||
},
|
||||
"actions": {
|
||||
"launchProfile": "启动 {{name}}",
|
||||
"stopProfile": "停止 {{name}}",
|
||||
"profileInfo": "信息 — {{name}}"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"openPalette": "打开命令面板",
|
||||
"openShortcuts": "查看键盘快捷键",
|
||||
"importProfile": "导入配置文件",
|
||||
"goProfiles": "转到配置文件",
|
||||
"goProxies": "转到网络",
|
||||
"goExtensions": "转到扩展程序",
|
||||
"goGroups": "转到分组",
|
||||
"goIntegrations": "转到集成",
|
||||
"goAccount": "转到账户",
|
||||
"goSettings": "转到设置"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user