Compare commits

...

20 Commits

Author SHA1 Message Date
zhom 36263eac04 feat: add shortcuts 2026-05-17 21:02:11 +04:00
zhom 9e777ed37b refactor: reduce token usage 2026-05-17 21:02:11 +04:00
zhom 4d59805989 chore: use less tokens 2026-05-17 21:02:11 +04:00
zhom 28d135de06 fix: track gecko_id for extension groups 2026-05-17 21:02:11 +04:00
zhom d234172d0a chore: improve issue validation 2026-05-17 21:02:11 +04:00
andy 6cd257c40b Merge pull request #372 from zhom/dependabot/github_actions/github-actions-4cf24cbed6
ci(deps): bump the github-actions group across 1 directory with 6 updates
2026-05-17 19:01:54 +02:00
dependabot[bot] 7446f678d4 ci(deps): bump the github-actions group across 1 directory with 6 updates
Bumps the github-actions group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [pnpm/action-setup](https://github.com/pnpm/action-setup) | `6.0.6` | `6.0.8` |
| [google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml](https://github.com/google/osv-scanner-action) | `2.3.5` | `2.3.8` |
| [anomalyco/opencode](https://github.com/anomalyco/opencode) | `1.14.41` | `1.15.3` |
| [google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml](https://github.com/google/osv-scanner-action) | `2.3.5` | `2.3.8` |
| [actions/ai-inference](https://github.com/actions/ai-inference) | `2.0.7` | `2.1.0` |
| [crate-ci/typos](https://github.com/crate-ci/typos) | `1.46.1` | `1.46.2` |



Updates `pnpm/action-setup` from 6.0.6 to 6.0.8
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/91ab88e2619ed1f46221f0ba42d1492c02baf788...0e279bb959325dab635dd2c09392533439d90093)

Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml` from 2.3.5 to 2.3.8
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/c51854704019a247608d928f370c98740469d4b5...9a498708959aeaef5ef730655706c5a1df1edbc2)

Updates `anomalyco/opencode` from 1.14.41 to 1.15.3
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/8ba2a9171597262df9d19516c82a5e14f18f5c63...37f89b742907c43b20d38b68eabe65981a59690a)

Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml` from 2.3.5 to 2.3.8
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/c51854704019a247608d928f370c98740469d4b5...9a498708959aeaef5ef730655706c5a1df1edbc2)

Updates `actions/ai-inference` from 2.0.7 to 2.1.0
- [Release notes](https://github.com/actions/ai-inference/releases)
- [Commits](https://github.com/actions/ai-inference/compare/e09e65981758de8b2fdab13c2bfb7c7d5493b0b6...17ff458cb182449bbb2e43701fcd98f6af8f6570)

Updates `crate-ci/typos` from 1.46.1 to 1.46.2
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/5374cbf686e897b15713110e233094e2874de7ef...aca895bf05aec0cb7dffa6f94495e923224d9f17)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml
  dependency-version: 2.3.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: anomalyco/opencode
  dependency-version: 1.15.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml
  dependency-version: 2.3.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: actions/ai-inference
  dependency-version: 2.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.46.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 17:00:42 +00:00
andy 72e2b99b9e Merge pull request #371 from zhom/dependabot/cargo/src-tauri/rust-dependencies-60b6c910ca
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 9 updates
2026-05-17 17:13:37 +02:00
dependabot[bot] 98b83aaf5a deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [bzip2](https://github.com/trifectatechfoundation/bzip2-rs) | `0.5.2` | `0.6.1` |
| [toml](https://github.com/toml-rs/toml) | `0.9.12+spec-1.1.0` | `1.1.2+spec-1.1.0` |
| [quick-xml](https://github.com/tafia/quick-xml) | `0.39.4` | `0.40.1` |
| [filetime](https://github.com/alexcrichton/filetime) | `0.2.28` | `0.2.29` |
| [kurbo](https://github.com/linebender/kurbo) | `0.13.0` | `0.13.1` |
| [open](https://github.com/Byron/open-rs) | `5.3.4` | `5.3.5` |
| [pin-project](https://github.com/taiki-e/pin-project) | `1.1.12` | `1.1.13` |
| [pin-project-internal](https://github.com/taiki-e/pin-project) | `1.1.12` | `1.1.13` |
| [zerofrom](https://github.com/unicode-org/icu4x) | `0.1.7` | `0.1.8` |


Updates `bzip2` from 0.5.2 to 0.6.1
- [Release notes](https://github.com/trifectatechfoundation/bzip2-rs/releases)
- [Commits](https://github.com/trifectatechfoundation/bzip2-rs/compare/v0.5.2...v0.6.1)

Updates `toml` from 0.9.12+spec-1.1.0 to 1.1.2+spec-1.1.0
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.9.12...toml-v1.1.2)

Updates `quick-xml` from 0.39.4 to 0.40.1
- [Release notes](https://github.com/tafia/quick-xml/releases)
- [Changelog](https://github.com/tafia/quick-xml/blob/master/Changelog.md)
- [Commits](https://github.com/tafia/quick-xml/compare/v0.39.4...v0.40.1)

Updates `filetime` from 0.2.28 to 0.2.29
- [Commits](https://github.com/alexcrichton/filetime/compare/0.2.28...0.2.29)

Updates `kurbo` from 0.13.0 to 0.13.1
- [Release notes](https://github.com/linebender/kurbo/releases)
- [Changelog](https://github.com/linebender/kurbo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/linebender/kurbo/compare/v0.13.0...v0.13.1)

Updates `open` from 5.3.4 to 5.3.5
- [Release notes](https://github.com/Byron/open-rs/releases)
- [Changelog](https://github.com/Byron/open-rs/blob/main/changelog.md)
- [Commits](https://github.com/Byron/open-rs/compare/v5.3.4...v5.3.5)

Updates `pin-project` from 1.1.12 to 1.1.13
- [Release notes](https://github.com/taiki-e/pin-project/releases)
- [Changelog](https://github.com/taiki-e/pin-project/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/pin-project/compare/v1.1.12...v1.1.13)

Updates `pin-project-internal` from 1.1.12 to 1.1.13
- [Release notes](https://github.com/taiki-e/pin-project/releases)
- [Changelog](https://github.com/taiki-e/pin-project/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/pin-project/compare/v1.1.12...v1.1.13)

Updates `zerofrom` from 0.1.7 to 0.1.8
- [Release notes](https://github.com/unicode-org/icu4x/releases)
- [Changelog](https://github.com/unicode-org/icu4x/blob/main/CHANGELOG.md)
- [Commits](https://github.com/unicode-org/icu4x/commits)

---
updated-dependencies:
- dependency-name: bzip2
  dependency-version: 0.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: toml
  dependency-version: 1.1.2+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: rust-dependencies
- dependency-name: quick-xml
  dependency-version: 0.40.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: filetime
  dependency-version: 0.2.29
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: kurbo
  dependency-version: 0.13.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: open
  dependency-version: 5.3.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: pin-project
  dependency-version: 1.1.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: pin-project-internal
  dependency-version: 1.1.13
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zerofrom
  dependency-version: 0.1.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-16 09:42:11 +00:00
github-actions[bot] 99074280ea chore: update flake.nix for v0.24.2 [skip ci] (#370)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-16 09:29:04 +00:00
github-actions[bot] 85586ed8fa docs: update CHANGELOG.md and README.md for v0.24.2 [skip ci] (#369)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-16 09:28:42 +00:00
zhom 2e891dd9ec chore: version bump 2026-05-16 02:43:17 +04:00
zhom e5361b6905 fix: camoufox proxy pid connection 2026-05-16 02:41:28 +04:00
zhom f6daa642d0 refactor: browser update 2026-05-15 20:42:25 +04:00
zhom c84d547a8c feat: more mcp integrations 2026-05-15 19:59:44 +04:00
zhom c8a43b43f1 refactor: ui cleanup 2026-05-15 15:44:20 +04:00
zhom 56b0da990b refactor: cleanup 2026-05-14 20:04:19 +04:00
zhom 597efb7e58 chore: cleanup 2026-05-14 20:03:22 +04:00
github-actions[bot] ba72e4cb3b chore: update flake.nix for v0.24.1 [skip ci] (#364)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 18:31:28 +00:00
github-actions[bot] c2ace4b8d3 docs: update CHANGELOG.md and README.md for v0.24.1 [skip ci] (#363)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-12 18:31:10 +00:00
100 changed files with 7299 additions and 2328 deletions
+1 -1
View File
@@ -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
+108
View File
@@ -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`);
}
+1 -1
View File
@@ -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
+306
View File
@@ -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 -10
View File
@@ -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:
@@ -159,10 +159,14 @@ jobs:
numbers. Never speculate about how subscription / paid-plan checks work.
# OS-SPECIFIC LOG PATHS (use ONLY the one matching the user's OS)
# Easiest path for the user: Donut → Settings → Advanced → Copy logs
# (puts the latest rotated log on the clipboard). If they prefer to
# attach files directly, the active log is `DonutBrowser.log`; older
# rotated copies sit next to it (`DonutBrowser.log.YYYY-MM-DD-…`).
- macOS: `~/Library/Logs/com.donutbrowser/`
- Linux: `~/.local/share/com.donutbrowser/logs/`
- Windows: `%APPDATA%\com.donutbrowser\logs\`
- macOS: `~/Library/Logs/com.donutbrowser/DonutBrowser.log`
- Linux: `~/.local/share/com.donutbrowser/logs/DonutBrowser.log`
- Windows: `%LOCALAPPDATA%\com.donutbrowser\logs\DonutBrowser.log`
# KNOWN ERROR SIGNATURES (truth, not guesses — match these
# verbatim before suggesting anything else)
@@ -352,10 +356,14 @@ jobs:
If the issue body is not in English, write the comment in English (the maintainer reads English). The FIRST line must politely ask the user to communicate in English so the maintainer can help. Then continue with the normal triage response, in English.
## OS-specific log paths
Use ONLY the one matching `triage.operating_system`:
- macos: `~/Library/Logs/com.donutbrowser/`
- linux: `~/.local/share/com.donutbrowser/logs/`
- windows: `%APPDATA%\com.donutbrowser\logs\` (PowerShell-friendly: `Get-ChildItem $env:APPDATA\com.donutbrowser\logs`)
Recommend Settings → Advanced → Copy logs first — it bundles the
latest rotated log onto the clipboard without the user hunting for
a directory. If they want to attach files directly, point at the
path that matches `triage.operating_system`. The active log is
always `DonutBrowser.log`; rotated copies sit next to it.
- macos: `~/Library/Logs/com.donutbrowser/DonutBrowser.log`
- linux: `~/.local/share/com.donutbrowser/logs/DonutBrowser.log`
- windows: `%LOCALAPPDATA%\com.donutbrowser\logs\DonutBrowser.log` (PowerShell: `Get-Content $env:LOCALAPPDATA\com.donutbrowser\logs\DonutBrowser.log -Tail 200`)
- unknown: ask the user to share their OS first.
## Known error signatures (apply BEFORE asking generic questions)
@@ -607,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 }}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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: |
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+92
View File
@@ -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
@@ -69,6 +71,86 @@ donutbrowser/
- Strings excluded from this rule: `console.log/warn/error`, dev-only debug labels, internal IDs, CSS class names, type names. If unsure whether a string renders to the user, assume it does and translate it.
- **Never use `t(key, "fallback")` with a default-value second argument.** The 2-arg form is forbidden — every key must exist in every locale file before the call site lands. Fallbacks mask missing translations: a key missing from `ru.json` will silently render the English fallback to Russian users, so the bug never surfaces in CI or review. Only call `t("namespace.key")`. If a translation is missing for any locale, that's a bug to fix at the JSON, not a hole to paper over at the call site.
- Empty-string values in non-English locales are also forbidden — a locale either has the right translation or it has the same content as English; never `""`. If a particular language doesn't need a particular phrase (e.g. a suffix that doesn't grammatically apply), refactor the JSX to use a single interpolated key (`t("foo.bar", { name })` with `"...{{name}}..."` in each locale) instead of splitting prefix/suffix.
- When adding or removing keys across all seven locales, use a one-shot Python script in `/tmp/` that loads each `*.json`, mutates it, and writes it back. Seven sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away.
## Backend error codes (mandatory)
User-facing errors returned from a Tauri command MUST be JSON `{ "code": "FOO_BAR", "params": { … } }` strings — never raw English (`format!("Failed to …")`). The frontend resolves the code via `translateBackendError(t, err)` from `src/lib/backend-errors.ts`. Adding a new code requires four parallel edits:
1. Emit the JSON from Rust:
```rust
return Err(serde_json::json!({ "code": "FOO_BAR" }).to_string());
// or with params:
return Err(serde_json::json!({ "code": "FOO_BAR", "params": { "n": "5" } }).to_string());
```
2. Add `"FOO_BAR"` to the `BackendErrorCode` union in `src/lib/backend-errors.ts`.
3. Add a `case "FOO_BAR":` in the switch that returns `t("backendErrors.fooBar", …)`.
4. Add `backendErrors.fooBar` to all seven locale files.
Raw error strings reach the user untranslated; that's the bug pattern this rule blocks.
## Sub-page Dialog mode
A `<Dialog>` becomes a first-class app sub-page (no modal overlay, no center positioning) when `subPage` is passed. Pages like Account, Settings, Proxy Management, and Extension Management use this. The pattern for a sub-page with tabs:
```tsx
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl flex flex-col">
<Tabs defaultValue="account">
<TabsList
className={cn(
"w-full",
subPage &&
"!bg-transparent !p-0 !h-auto !rounded-none justify-start gap-4",
)}
>
<TabsTrigger
value="account"
className={cn(
"flex-1",
subPage &&
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs",
)}
>
Account
</TabsTrigger>
</TabsList>
<TabsContent value="account" className="mt-4">…</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
```
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 19 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
@@ -93,6 +175,16 @@ donutbrowser/
- Use these as Tailwind classes: `bg-success`, `text-destructive`, `border-warning`, etc.
- For lighter variants use opacity: `bg-destructive/10`, `bg-success/10`, `border-warning/50`
## App data directory naming
`src-tauri/src/app_dirs.rs::app_name()` returns `"DonutBrowserDev"` when `cfg!(debug_assertions)` is true, `"DonutBrowser"` otherwise. So release builds (anything built via `tauri build` / `cargo build --release`) write to:
- macOS — `~/Library/Application Support/DonutBrowser/`
- Linux — `~/.local/share/DonutBrowser/`
- Windows — `%LOCALAPPDATA%\DonutBrowser\`
Debug builds (`cargo build`, `pnpm tauri dev`) write to the `DonutBrowserDev` sibling at the same root, and a `dev-{version}` `BUILD_VERSION` is injected via `build.rs`. Logs / screenshots referencing `DonutBrowserDev` therefore mean a local dev build is in play, not a release; useful when a bug report seems to disagree with what production users see.
## Publishing Linux Repositories
The `scripts/publish-repo.sh` script publishes DEB and RPM packages to Cloudflare R2 (served at `repo.donutbrowser.com`). It requires Linux tools, so run it in Docker on macOS:
+35
View File
@@ -1,6 +1,41 @@
# 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
- creation button disaster recovery
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.24.0 [skip ci] (#357)
## v0.24.0 (2026-05-12)
### Features
+5 -5
View File
@@ -48,7 +48,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_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.0/Donut_0.24.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_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.0/Donut_0.24.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut-0.24.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut-0.24.0-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_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:
+5 -5
View File
@@ -94,17 +94,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.24.0";
releaseVersion = "0.24.2";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_amd64.AppImage";
hash = "sha256-tidp6JvFPCbsPzZldeG4697dzQjhYv83DouzgxS+lKY=";
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.0/Donut_0.24.0_aarch64.AppImage";
hash = "sha256-9kHwDafQ+UsKeOeJ+7DbXGGeugogn+NjnhUBYxUeUUo=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_aarch64.AppImage";
hash = "sha256-QPGV6XO0ugPAJSbPJrVwDsEb9lw3dcL6IdU17UCYH4E=";
}
else
null;
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.24.1",
"version": "0.24.2",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
+53 -18
View File
@@ -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"
@@ -1784,7 +1793,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.24.1"
version = "0.24.2"
dependencies = [
"aes 0.9.0",
"aes-gcm",
@@ -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,6 +1867,7 @@ dependencies = [
"tokio",
"tokio-tungstenite",
"tokio-util",
"toml 1.1.2+spec-1.1.0",
"tower",
"tower-http",
"tray-icon 0.24.0",
@@ -2212,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",
@@ -3553,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",
]
@@ -3604,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"
@@ -4381,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",
@@ -4659,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",
@@ -4741,7 +4758,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
"quick-xml",
"quick-xml 0.39.4",
"serde",
"time",
]
@@ -4797,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"
@@ -5013,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",
@@ -8091,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",
]
@@ -9144,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",
]
@@ -9224,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",
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.24.1"
version = "0.24.2"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -100,11 +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 = "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"
+8 -2
View File
@@ -1582,7 +1582,10 @@ impl BrowserRunner {
}
if profile.password_protected {
crate::profile::password::complete_after_quit(profile);
// Await the re-encryption so the queued sync (released later by
// `mark_profile_stopped` in `kill_browser`) sees fresh ciphertext on
// disk instead of the previous snapshot.
crate::profile::password::complete_after_quit_and_wait(profile).await;
} else if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
}
@@ -1924,7 +1927,10 @@ impl BrowserRunner {
}
if profile.password_protected {
crate::profile::password::complete_after_quit(profile);
// Await the re-encryption so the queued sync (released later by
// `mark_profile_stopped` in `kill_browser`) sees fresh ciphertext on
// disk instead of the previous snapshot.
crate::profile::password::complete_after_quit_and_wait(profile).await;
} else if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
}
+22 -7
View File
@@ -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}");
}
}
}
+30 -9
View File
@@ -290,24 +290,45 @@ impl DownloadedBrowsersRegistry {
}
}
// Filter out versions that would leave a browser with zero versions in the registry
// For each browser where every registered version would be removed (no
// profile uses any), keep the newest one by semver. Without this, the
// version preserved depends on HashMap iteration order, so a freshly
// downloaded version can be deleted in favor of an older orphan — leaving
// the UI stuck on "needs to be downloaded".
{
let data = self.data.lock().unwrap();
let mut removal_counts: std::collections::HashMap<String, usize> =
let mut removal_versions_by_browser: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for (browser, _) in &to_remove {
*removal_counts.entry(browser.clone()).or_insert(0) += 1;
for (browser, version) in &to_remove {
removal_versions_by_browser
.entry(browser.clone())
.or_default()
.push(version.clone());
}
to_remove.retain(|(browser, version)| {
let mut keep_per_browser: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for (browser, versions) in &removal_versions_by_browser {
let total = data
.browsers
.get(browser.as_str())
.map(|v| v.len())
.unwrap_or(0);
let removing = *removal_counts.get(browser.as_str()).unwrap_or(&0);
if removing >= total {
log::info!("Keeping last available version: {browser} {version}");
*removal_counts.get_mut(browser.as_str()).unwrap() -= 1;
if versions.len() >= total {
if let Some(latest) = versions
.iter()
.max_by(|a, b| crate::api_client::compare_versions(a, b))
{
keep_per_browser.insert(browser.clone(), latest.clone());
}
}
}
drop(data);
to_remove.retain(|(browser, version)| {
if keep_per_browser
.get(browser)
.is_some_and(|keep| keep == version)
{
log::info!("Keeping latest available version: {browser} {version}");
return false;
}
true
+99 -37
View File
@@ -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);
+3 -13
View File
@@ -268,7 +268,9 @@ impl GroupManager {
}
}
// Create result including all groups (even those with 0 count)
// Create result including all groups (even those with 0 count).
// The "Default" pseudo-group is intentionally not returned: profiles
// without a group_id are surfaced through the "All" filter instead.
let mut result = Vec::new();
for group in groups {
let count = group_counts.get(&group.id).copied().unwrap_or(0);
@@ -281,18 +283,6 @@ impl GroupManager {
});
}
// Add default group count (profiles without group_id), always include even if 0
let default_count = profiles.iter().filter(|p| p.group_id.is_none()).count();
let default_group = GroupWithCount {
id: "default".to_string(),
name: "Default".to_string(),
count: default_count,
sync_enabled: false,
last_sync: None,
};
// Insert at the beginning for consistent ordering with UI expectations
result.insert(0, default_group);
Ok(result)
}
}
+60 -97
View File
@@ -52,6 +52,7 @@ pub mod daemon_client;
mod daemon_spawn;
pub mod daemon_ws;
pub mod events;
mod mcp_integrations;
mod mcp_server;
mod tag_manager;
mod team_lock;
@@ -74,7 +75,7 @@ use profile::manager::{
use profile::password::{
change_profile_password, is_profile_locked, lock_profile, remove_profile_password,
set_profile_password, unlock_profile,
set_profile_password, unlock_profile, verify_profile_password,
};
use browser_version_manager::{
@@ -103,6 +104,7 @@ use sync::{
is_vpn_in_use_by_synced_profile, request_profile_sync, rollover_encryption_for_all_entities,
set_e2e_password, set_extension_group_sync_enabled, set_extension_sync_enabled,
set_group_sync_enabled, set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled,
verify_e2e_password,
};
use tag_manager::get_all_tags;
@@ -503,20 +505,20 @@ fn claude_desktop_extension_dir() -> Option<std::path::PathBuf> {
}
}
#[tauri::command]
fn is_mcp_in_claude_desktop() -> Result<bool, String> {
let dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
Ok(dir.join("manifest.json").exists())
fn is_mcp_in_claude_desktop_internal() -> bool {
let Some(dir) = claude_desktop_extension_dir() else {
return false;
};
dir.join("manifest.json").exists()
}
#[tauri::command]
async fn add_mcp_to_claude_desktop(app_handle: tauri::AppHandle) -> Result<(), String> {
async fn add_mcp_to_claude_desktop_internal(app_handle: &tauri::AppHandle) -> Result<(), String> {
let mcp_server = mcp_server::McpServer::instance();
let port = mcp_server.get_port().ok_or("MCP server is not running")?;
let settings_manager = settings_manager::SettingsManager::instance();
let token = settings_manager
.get_mcp_token(&app_handle)
.get_mcp_token(app_handle)
.await
.map_err(|e| format!("Failed to get MCP token: {e}"))?
.ok_or("MCP token not found")?;
@@ -605,8 +607,7 @@ rl.on("close", () => setTimeout(() => process.exit(0), 500));
Ok(())
}
#[tauri::command]
fn remove_mcp_from_claude_desktop() -> Result<(), String> {
fn remove_mcp_from_claude_desktop_internal() -> Result<(), String> {
let ext_dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
if ext_dir.exists() {
std::fs::remove_dir_all(&ext_dir).map_err(|e| format!("Failed to remove extension: {e}"))?;
@@ -668,91 +669,48 @@ fn update_claude_extensions_registry(
Ok(())
}
fn find_claude_cli() -> Option<std::path::PathBuf> {
let mut candidates: Vec<std::path::PathBuf> = vec![
std::path::PathBuf::from("/usr/local/bin/claude"),
std::path::PathBuf::from("/opt/homebrew/bin/claude"),
];
if let Some(home) = dirs::home_dir() {
candidates.insert(0, home.join(".local/bin/claude"));
candidates.push(home.join(".claude/local/claude"));
}
#[cfg(windows)]
if let Ok(appdata) = std::env::var("APPDATA") {
candidates.insert(
0,
std::path::PathBuf::from(appdata).join("Claude/claude.exe"),
);
}
for p in &candidates {
if p.exists() {
return Some(p.clone());
}
}
None
}
#[tauri::command]
async fn is_mcp_in_claude_code() -> Result<bool, String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
// `claude mcp list` health-checks every registered MCP server, so a
// missing or stalled server can hang the call for many seconds. Cap it
// — for this dialog, a slow `claude` is treated the same as "not registered".
let fut = tokio::process::Command::new(&cli)
.args(["mcp", "list"])
.output();
let output = tokio::time::timeout(std::time::Duration::from_secs(2), fut)
.await
.map_err(|_| "claude mcp list timed out".to_string())?
.map_err(|e| format!("Failed to run claude: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.contains("donut-browser"))
}
#[tauri::command]
async fn add_mcp_to_claude_code(app_handle: tauri::AppHandle) -> Result<(), String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
async fn current_mcp_url(app_handle: &tauri::AppHandle) -> Result<String, String> {
let mcp_server = mcp_server::McpServer::instance();
let port = mcp_server.get_port().ok_or("MCP server is not running")?;
let settings_manager = settings_manager::SettingsManager::instance();
let token = settings_manager
.get_mcp_token(&app_handle)
.get_mcp_token(app_handle)
.await
.map_err(|e| format!("Failed to get MCP token: {e}"))?
.ok_or("MCP token not found")?;
let url = format!("http://127.0.0.1:{port}/mcp/{token}");
let _ = std::process::Command::new(&cli)
.args(["mcp", "remove", "donut-browser"])
.output();
let output = std::process::Command::new(&cli)
.args(["mcp", "add", "--transport", "http", "donut-browser", &url])
.output()
.map_err(|e| format!("Failed to run claude: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to add MCP to Claude Code: {stderr}"));
}
Ok(())
Ok(format!("http://127.0.0.1:{port}/mcp/{token}"))
}
#[tauri::command]
fn remove_mcp_from_claude_code() -> Result<(), String> {
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
let output = std::process::Command::new(&cli)
.args(["mcp", "remove", "donut-browser"])
.output()
.map_err(|e| format!("Failed to run claude: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to remove MCP from Claude Code: {stderr}"));
async fn list_mcp_agents() -> Result<Vec<mcp_integrations::McpAgentInfo>, String> {
let claude_desktop_connected = is_mcp_in_claude_desktop_internal();
Ok(mcp_integrations::list_agents_with_status(&[(
"claude-desktop",
claude_desktop_connected,
)]))
}
#[tauri::command]
async fn add_mcp_to_agent(app_handle: tauri::AppHandle, agent_id: String) -> Result<(), String> {
if !mcp_integrations::agent_exists(&agent_id) {
return Err(format!("Unknown agent: {agent_id}"));
}
Ok(())
if agent_id == "claude-desktop" {
return add_mcp_to_claude_desktop_internal(&app_handle).await;
}
let url = current_mcp_url(&app_handle).await?;
mcp_integrations::install_generic(&agent_id, &url)
}
#[tauri::command]
async fn remove_mcp_from_agent(agent_id: String) -> Result<(), String> {
if !mcp_integrations::agent_exists(&agent_id) {
return Err(format!("Unknown agent: {agent_id}"));
}
if agent_id == "claude-desktop" {
return remove_mcp_from_claude_desktop_internal();
}
mcp_integrations::uninstall_generic(&agent_id)
}
#[tauri::command]
@@ -1822,6 +1780,19 @@ pub fn run() {
);
}
// Re-encrypt password-protected profiles when the browser
// exits naturally (user closing the window) — the explicit
// kill path in browser_runner.rs handles app-driven stops.
// Must run BEFORE `mark_profile_stopped` because that
// releases any queued sync run, and a sync that picks up
// the on-disk dir before re-encryption finishes uploads
// the previous snapshot (issue: encrypted profiles not
// syncing fresh data).
if !is_running && profile.password_protected {
crate::profile::password::complete_after_quit_and_wait(&profile)
.await;
}
// Notify sync scheduler of running state changes
if let Some(scheduler) = sync::get_global_scheduler() {
if is_running {
@@ -1832,13 +1803,6 @@ pub fn run() {
}
}
// Re-encrypt password-protected profiles when the browser
// exits naturally (user closing the window) — the explicit
// kill path in browser_runner.rs handles app-driven stops.
if !is_running && profile.password_protected {
crate::profile::password::complete_after_quit(&profile);
}
last_running_states.insert(profile_id, is_running);
} else {
// Update the state even if unchanged to ensure we have it tracked
@@ -2106,6 +2070,7 @@ pub fn run() {
enable_sync_for_all_entities,
set_e2e_password,
check_has_e2e_password,
verify_e2e_password,
delete_e2e_password,
rollover_encryption_for_all_entities,
read_profile_cookies,
@@ -2123,12 +2088,9 @@ pub fn run() {
stop_mcp_server,
get_mcp_server_status,
get_mcp_config,
is_mcp_in_claude_desktop,
add_mcp_to_claude_desktop,
remove_mcp_from_claude_desktop,
is_mcp_in_claude_code,
add_mcp_to_claude_code,
remove_mcp_from_claude_code,
list_mcp_agents,
add_mcp_to_agent,
remove_mcp_from_agent,
// VPN commands
import_vpn_config,
list_vpn_configs,
@@ -2171,6 +2133,7 @@ pub fn run() {
set_profile_password,
change_profile_password,
remove_profile_password,
verify_profile_password,
unlock_profile,
lock_profile,
is_profile_locked,
+574
View File
@@ -0,0 +1,574 @@
// MCP client integrations — installs/removes the donut-browser MCP server in
// 14 popular AI assistant clients. Ports the add-mcp registry to Rust.
//
// Claude Desktop is managed via Claude's local extensions bundle
// (manifest.json + node bridge), since the desktop app supports only stdio
// servers via its plain JSON config but exposes HTTP through the extension
// framework. See `add_mcp_to_claude_desktop_internal` in lib.rs. All other
// agents (including Claude Code) use the generic config-file installer here.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
const SERVER_NAME: &str = "donut-browser";
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "kebab-case")]
pub enum AgentCategory {
DesktopApp,
Cli,
Editor,
EditorExt,
}
#[derive(Debug, Clone, Copy)]
enum ConfigFormat {
Json,
Toml,
Yaml,
}
#[derive(Debug, Clone)]
struct AgentSpec {
id: &'static str,
display_name: &'static str,
category: AgentCategory,
/// Top-level key (supports dot notation) where the server is written.
config_key: &'static str,
format: ConfigFormat,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct McpAgentInfo {
pub id: String,
pub display_name: String,
pub category: AgentCategory,
pub connected: bool,
/// True when the underlying client appears to be installed on the system
/// (its config directory exists), regardless of whether we have installed
/// the donut-browser server into it.
pub detected: bool,
}
fn home() -> Option<PathBuf> {
dirs::home_dir()
}
#[cfg(target_os = "macos")]
fn vscode_user_dir() -> Option<PathBuf> {
home().map(|h| {
h.join("Library")
.join("Application Support")
.join("Code")
.join("User")
})
}
#[cfg(target_os = "windows")]
fn vscode_user_dir() -> Option<PathBuf> {
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("Code").join("User"))
}
#[cfg(target_os = "linux")]
fn vscode_user_dir() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("Code").join("User"))
}
#[cfg(target_os = "macos")]
fn zed_config_dir() -> Option<PathBuf> {
home().map(|h| h.join("Library").join("Application Support").join("Zed"))
}
#[cfg(target_os = "windows")]
fn zed_config_dir() -> Option<PathBuf> {
std::env::var("APPDATA")
.ok()
.map(|a| PathBuf::from(a).join("Zed"))
}
#[cfg(target_os = "linux")]
fn zed_config_dir() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("zed"))
}
#[cfg(target_os = "windows")]
fn goose_config_path() -> Option<PathBuf> {
std::env::var("APPDATA").ok().map(|a| {
PathBuf::from(a)
.join("Block")
.join("goose")
.join("config")
.join("config.yaml")
})
}
#[cfg(not(target_os = "windows"))]
fn goose_config_path() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("goose").join("config.yaml"))
}
/// Resolve the global config path for an agent. Returns `None` on unsupported
/// platforms (none currently — every supported agent has a defined path on
/// macOS/Linux/Windows).
fn config_path_for(agent_id: &str) -> Option<PathBuf> {
let h = home()?;
match agent_id {
"antigravity" => Some(
h.join(".gemini")
.join("antigravity")
.join("mcp_config.json"),
),
"cline" => vscode_user_dir().map(|d| {
d.join("globalStorage")
.join("saoudrizwan.claude-dev")
.join("settings")
.join("cline_mcp_settings.json")
}),
"cline-cli" => {
let base = std::env::var("CLINE_DIR")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| h.join(".cline"));
Some(
base
.join("data")
.join("settings")
.join("cline_mcp_settings.json"),
)
}
"claude-code" => Some(h.join(".claude.json")),
"claude-desktop" => claude_desktop_config_path(),
"codex" => {
let base = std::env::var("CODEX_HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| h.join(".codex"));
Some(base.join("config.toml"))
}
"cursor" => Some(h.join(".cursor").join("mcp.json")),
"gemini-cli" => Some(h.join(".gemini").join("settings.json")),
"goose" => goose_config_path(),
"github-copilot-cli" => Some(
std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| h.join(".copilot"))
.join("mcp-config.json"),
),
"mcporter" => {
// add-mcp's resolveMcporterConfigPath: prefer mcporter.json, fall back
// to mcporter.jsonc if it already exists, else default to mcporter.json.
let dir = h.join(".mcporter");
let json_path = dir.join("mcporter.json");
let jsonc_path = dir.join("mcporter.jsonc");
if json_path.exists() {
Some(json_path)
} else if jsonc_path.exists() {
Some(jsonc_path)
} else {
Some(json_path)
}
}
"opencode" => Some(h.join(".config").join("opencode").join("opencode.json")),
"vscode" => vscode_user_dir().map(|d| d.join("mcp.json")),
"zed" => zed_config_dir().map(|d| d.join("settings.json")),
_ => None,
}
}
#[cfg(target_os = "macos")]
fn claude_desktop_config_path() -> Option<PathBuf> {
home().map(|h| {
h.join("Library")
.join("Application Support")
.join("Claude")
.join("claude_desktop_config.json")
})
}
#[cfg(target_os = "windows")]
fn claude_desktop_config_path() -> Option<PathBuf> {
std::env::var("APPDATA").ok().map(|a| {
PathBuf::from(a)
.join("Claude")
.join("claude_desktop_config.json")
})
}
#[cfg(target_os = "linux")]
fn claude_desktop_config_path() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| home().map(|h| h.join(".config")))?;
Some(base.join("Claude").join("claude_desktop_config.json"))
}
const AGENT_SPECS: &[AgentSpec] = &[
AgentSpec {
id: "claude-desktop",
display_name: "Claude Desktop",
category: AgentCategory::DesktopApp,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "claude-code",
display_name: "Claude Code",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "cursor",
display_name: "Cursor",
category: AgentCategory::Editor,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "vscode",
display_name: "VS Code",
category: AgentCategory::Editor,
config_key: "servers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "zed",
display_name: "Zed",
category: AgentCategory::Editor,
config_key: "context_servers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "cline-cli",
display_name: "Cline CLI",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "cline",
display_name: "Cline VSCode",
category: AgentCategory::EditorExt,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "codex",
display_name: "Codex",
category: AgentCategory::Cli,
config_key: "mcp_servers",
format: ConfigFormat::Toml,
},
AgentSpec {
id: "gemini-cli",
display_name: "Gemini CLI",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "github-copilot-cli",
display_name: "GitHub Copilot CLI",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "goose",
display_name: "Goose",
category: AgentCategory::Cli,
config_key: "extensions",
format: ConfigFormat::Yaml,
},
AgentSpec {
id: "antigravity",
display_name: "Antigravity",
category: AgentCategory::DesktopApp,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
AgentSpec {
id: "opencode",
display_name: "OpenCode",
category: AgentCategory::Cli,
config_key: "mcp",
format: ConfigFormat::Json,
},
AgentSpec {
id: "mcporter",
display_name: "MCPorter",
category: AgentCategory::Cli,
config_key: "mcpServers",
format: ConfigFormat::Json,
},
];
fn spec_for(agent_id: &str) -> Option<&'static AgentSpec> {
AGENT_SPECS.iter().find(|s| s.id == agent_id)
}
fn detect_agent_directory(agent_id: &str) -> bool {
// Mirrors add-mcp's `detectGlobalInstall` checks — typically the immediate
// parent of the config file. Used only for UI annotation; install/uninstall
// always operates on the resolved config path.
let Some(h) = home() else {
return false;
};
match agent_id {
"antigravity" => h.join(".gemini").exists(),
"cline" => config_path_for("cline")
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"cline-cli" => config_path_for("cline-cli")
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"claude-code" => h.join(".claude").exists(),
"claude-desktop" => claude_desktop_config_path()
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"codex" => h.join(".codex").exists(),
"cursor" => h.join(".cursor").exists(),
"gemini-cli" => h.join(".gemini").exists(),
"github-copilot-cli" => config_path_for("github-copilot-cli")
.and_then(|p| p.parent().map(|d| d.exists()))
.unwrap_or(false),
"goose" => goose_config_path().is_some_and(|p| p.exists()),
"mcporter" => h.join(".mcporter").exists(),
"opencode" => h.join(".config").join("opencode").exists(),
"vscode" => vscode_user_dir().is_some_and(|d| d.exists()),
"zed" => zed_config_dir().is_some_and(|d| d.exists()),
_ => false,
}
}
/// Transform the donut-browser HTTP server config into the per-agent shape.
/// All agents speak HTTP except Claude Desktop, which uses a node stdio bridge
/// (handled by the extension installer in lib.rs).
fn transform_remote_config(agent_id: &str, url: &str) -> serde_json::Value {
use serde_json::json;
match agent_id {
"zed" => json!({ "source": "custom", "type": "http", "url": url }),
"opencode" => json!({ "type": "remote", "url": url, "enabled": true }),
"antigravity" => json!({ "serverUrl": url }),
"cursor" => json!({ "url": url }),
"cline" | "cline-cli" => json!({
"url": url,
"type": "streamableHttp",
"disabled": false,
}),
"codex" => json!({ "type": "http", "url": url }),
"github-copilot-cli" => json!({ "type": "http", "url": url, "tools": ["*"] }),
"goose" => json!({
"name": SERVER_NAME,
"description": "",
"type": "streamable_http",
"uri": url,
"headers": {},
"enabled": true,
"timeout": 300,
}),
"vscode" => json!({ "type": "http", "url": url }),
// claude-code, claude-desktop, gemini-cli, mcporter — passthrough
_ => json!({ "type": "http", "url": url }),
}
}
/// Detect whether a server config object looks like our donut-browser HTTP
/// endpoint by URL prefix. Matches across the various per-agent key shapes
/// (`url`, `uri`, `serverUrl`).
fn config_matches_donut(value: &serde_json::Value) -> bool {
for key in ["url", "uri", "serverUrl"] {
if let Some(s) = value.get(key).and_then(|v| v.as_str()) {
if s.contains("/mcp/")
&& (s.starts_with("http://127.0.0.1") || s.starts_with("http://localhost"))
{
return true;
}
}
}
false
}
fn read_value(path: &Path, format: ConfigFormat) -> serde_json::Value {
let Ok(content) = fs::read_to_string(path) else {
return serde_json::Value::Null;
};
match format {
ConfigFormat::Json => serde_json::from_str(&content).unwrap_or(serde_json::Value::Null),
ConfigFormat::Toml => toml::from_str::<toml::Value>(&content)
.ok()
.and_then(|t| serde_json::to_value(t).ok())
.unwrap_or(serde_json::Value::Null),
ConfigFormat::Yaml => serde_yaml::from_str::<serde_yaml::Value>(&content)
.ok()
.and_then(|y| serde_json::to_value(y).ok())
.unwrap_or(serde_json::Value::Null),
}
}
fn write_value(path: &Path, value: &serde_json::Value, format: ConfigFormat) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create config dir: {e}"))?;
}
let content = match format {
ConfigFormat::Json => {
serde_json::to_string_pretty(value).map_err(|e| format!("Failed to serialize JSON: {e}"))?
}
ConfigFormat::Toml => {
let toml_val: toml::Value = serde_json::from_value(value.clone())
.map_err(|e| format!("Failed to convert to TOML: {e}"))?;
toml::to_string_pretty(&toml_val).map_err(|e| format!("Failed to serialize TOML: {e}"))?
}
ConfigFormat::Yaml => {
let yaml_val: serde_yaml::Value = serde_yaml::from_str(
&serde_json::to_string(value).map_err(|e| format!("Failed to serialize: {e}"))?,
)
.map_err(|e| format!("Failed to convert to YAML: {e}"))?;
serde_yaml::to_string(&yaml_val).map_err(|e| format!("Failed to serialize YAML: {e}"))?
}
};
fs::write(path, content).map_err(|e| format!("Failed to write config: {e}"))?;
Ok(())
}
/// Navigate `config_key` (dot notation), creating object literals at each
/// missing level. Returns a mutable reference to the bottom container so the
/// caller can set/remove server entries.
fn ensure_nested_object<'a>(
root: &'a mut serde_json::Value,
config_key: &str,
) -> &'a mut serde_json::Map<String, serde_json::Value> {
if !root.is_object() {
*root = serde_json::Value::Object(serde_json::Map::new());
}
let mut current = root.as_object_mut().expect("just set to object");
let parts: Vec<&str> = config_key.split('.').collect();
for part in &parts {
let entry = current
.entry(part.to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if !entry.is_object() {
*entry = serde_json::Value::Object(serde_json::Map::new());
}
current = entry.as_object_mut().expect("just ensured object");
}
current
}
fn nested_object<'a>(
root: &'a serde_json::Value,
config_key: &str,
) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
let mut current = root.as_object()?;
for part in config_key.split('.') {
current = current.get(part)?.as_object()?;
}
Some(current)
}
fn is_generic_agent_connected(agent_id: &str) -> bool {
let Some(spec) = spec_for(agent_id) else {
return false;
};
let Some(path) = config_path_for(agent_id) else {
return false;
};
if !path.exists() {
return false;
}
let root = read_value(&path, spec.format);
let Some(servers) = nested_object(&root, spec.config_key) else {
return false;
};
if let Some(entry) = servers.get(SERVER_NAME) {
return config_matches_donut(entry);
}
servers.values().any(config_matches_donut)
}
/// Install or remove the donut-browser entry from a generic agent. Returns
/// `true` if a write happened. Callers handle higher-level dispatch (Claude
/// Desktop extension setup, Claude Code CLI invocation).
pub fn install_generic(agent_id: &str, url: &str) -> Result<(), String> {
let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?;
let path = config_path_for(agent_id)
.ok_or_else(|| format!("Unable to resolve config path for {agent_id}"))?;
let mut root = if path.exists() {
read_value(&path, spec.format)
} else {
serde_json::Value::Object(serde_json::Map::new())
};
if !root.is_object() {
root = serde_json::Value::Object(serde_json::Map::new());
}
let container = ensure_nested_object(&mut root, spec.config_key);
container.insert(
SERVER_NAME.to_string(),
transform_remote_config(agent_id, url),
);
write_value(&path, &root, spec.format)
}
pub fn uninstall_generic(agent_id: &str) -> Result<(), String> {
let spec = spec_for(agent_id).ok_or_else(|| format!("Unknown agent: {agent_id}"))?;
let Some(path) = config_path_for(agent_id) else {
return Ok(());
};
if !path.exists() {
return Ok(());
}
let mut root = read_value(&path, spec.format);
if !root.is_object() {
return Ok(());
}
let container = ensure_nested_object(&mut root, spec.config_key);
container.remove(SERVER_NAME);
write_value(&path, &root, spec.format)
}
pub fn list_agents_with_status(connected_overrides: &[(&str, bool)]) -> Vec<McpAgentInfo> {
AGENT_SPECS
.iter()
.map(|spec| {
let connected = connected_overrides
.iter()
.find(|(id, _)| *id == spec.id)
.map(|(_, c)| *c)
.unwrap_or_else(|| is_generic_agent_connected(spec.id));
McpAgentInfo {
id: spec.id.to_string(),
display_name: spec.display_name.to_string(),
category: spec.category,
connected,
detected: detect_agent_directory(spec.id),
}
})
.collect()
}
pub fn agent_exists(agent_id: &str) -> bool {
spec_for(agent_id).is_some()
}
+409 -1
View File
@@ -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(
+110 -7
View File
@@ -12,6 +12,20 @@ use std::path::{Path, PathBuf};
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
use url::Url;
fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension(match path.extension().and_then(|e| e.to_str()) {
Some(ext) => format!("{ext}.tmp"),
None => "tmp".to_string(),
});
{
let mut f = fs::File::create(&tmp)?;
use std::io::Write;
f.write_all(data)?;
f.sync_all()?;
}
fs::rename(&tmp, path)
}
pub struct ProfileManager {
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
@@ -396,7 +410,7 @@ impl ProfileManager {
create_dir_all(&profile_uuid_dir)?;
let json = serde_json::to_string_pretty(profile)?;
fs::write(profile_file, json)?;
atomic_write(&profile_file, json.as_bytes())?;
// Update tag suggestions after any save
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
@@ -421,8 +435,26 @@ impl ProfileManager {
if path.is_dir() {
let metadata_file = path.join("metadata.json");
if metadata_file.exists() {
let content = fs::read_to_string(&metadata_file)?;
let mut profile: BrowserProfile = serde_json::from_str(&content)?;
let content = match fs::read_to_string(&metadata_file) {
Ok(c) => c,
Err(e) => {
log::warn!(
"Skipping profile at {}: failed to read metadata.json: {e}",
path.display()
);
continue;
}
};
let mut profile: BrowserProfile = match serde_json::from_str(&content) {
Ok(p) => p,
Err(e) => {
log::warn!(
"Skipping profile at {}: invalid metadata.json: {e}",
path.display()
);
continue;
}
};
// Backfill host_os from browser config for profiles created before
// the field existed (or synced without it).
@@ -431,7 +463,7 @@ impl ProfileManager {
if let Some(os) = inferred_os {
profile.host_os = Some(os);
if let Ok(json) = serde_json::to_string_pretty(&profile) {
let _ = fs::write(&metadata_file, json);
let _ = atomic_write(&metadata_file, json.as_bytes());
}
}
}
@@ -473,6 +505,8 @@ impl ProfileManager {
// Save profile with new name
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Keep tag suggestions up to date after name change (rebuild from all profiles)
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
@@ -678,6 +712,8 @@ impl ProfileManager {
profile.group_id = group_id.clone();
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for new group if profile has sync enabled
if profile.is_sync_enabled() {
if let Some(ref new_group_id) = group_id {
@@ -732,6 +768,8 @@ impl ProfileManager {
// Save profile
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Update global tag suggestions from all profiles
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
@@ -766,6 +804,8 @@ impl ProfileManager {
// Save profile
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Emit profile note update event
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
@@ -792,6 +832,8 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit("profile-updated", &profile) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -821,6 +863,8 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -845,6 +889,8 @@ impl ProfileManager {
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
@@ -1060,6 +1106,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
log::info!(
"Camoufox configuration updated for profile '{}' (ID: {}).",
profile.name,
@@ -1120,6 +1168,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
log::info!(
"Wayfern configuration updated for profile '{}' (ID: {}).",
profile.name,
@@ -1174,6 +1224,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for new proxy if profile has sync enabled
if profile.is_sync_enabled() {
if let Some(ref new_proxy_id) = proxy_id {
@@ -1263,6 +1315,8 @@ impl ProfileManager {
format!("Failed to save profile: {e}").into()
})?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for the new VPN if profile has sync enabled.
if profile.is_sync_enabled() {
if let Some(ref new_vpn_id) = vpn_id {
@@ -1300,6 +1354,8 @@ impl ProfileManager {
profile.extension_group_id = extension_group_id.clone();
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
// Auto-enable sync for the new extension group if profile has sync
// enabled. The helper is sync internally; we fire-and-forget through
// the async runtime so any I/O doesn't block this caller.
@@ -1453,13 +1509,18 @@ impl ProfileManager {
};
let mut merged = latest_profile.clone();
let mut detected_stop = false;
if let Some(pid) = found_pid {
if merged.process_id != Some(pid) {
let old_pid = merged.process_id;
merged.process_id = Some(pid);
if let Err(e) = self.save_profile(&merged) {
log::warn!("Warning: Failed to update profile with new PID: {e}");
}
if let Some(prev) = old_pid {
let _ = crate::proxy_manager::PROXY_MANAGER.update_proxy_pid(prev, pid);
}
}
} else if merged.process_id.is_some() {
// Clear the PID if no process found
@@ -1467,6 +1528,15 @@ impl ProfileManager {
if let Err(e) = self.save_profile(&merged) {
log::warn!("Warning: Failed to clear profile PID: {e}");
}
detected_stop = true;
}
if detected_stop {
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(&app_handle, &merged)
{
merged = updated;
}
}
// Emit profile update event to frontend
@@ -1481,7 +1551,7 @@ impl ProfileManager {
// Check Camoufox status using CamoufoxManager
async fn check_camoufox_status(
&self,
_app_handle: &tauri::AppHandle,
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let launcher = self.camoufox_manager;
@@ -1510,10 +1580,14 @@ impl ProfileManager {
};
if latest.process_id != camoufox_process.processId {
let old_pid = latest.process_id;
latest.process_id = camoufox_process.processId;
if let Err(e) = self.save_profile(&latest) {
log::warn!("Warning: Failed to update Camoufox profile with process info: {e}");
}
if let (Some(prev), Some(new)) = (old_pid, camoufox_process.processId) {
let _ = crate::proxy_manager::PROXY_MANAGER.update_proxy_pid(prev, new);
}
// Emit profile update event to frontend
if let Err(e) = events::emit("profile-updated", &latest) {
@@ -1555,6 +1629,12 @@ impl ProfileManager {
log::warn!("Warning: Failed to clear Camoufox profile process info: {e}");
}
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(app_handle, &latest)
{
latest = updated;
}
if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -1591,6 +1671,12 @@ impl ProfileManager {
);
}
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(app_handle, &latest)
{
latest = updated;
}
// Emit profile update event to frontend
if let Err(e3) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e3}");
@@ -1605,7 +1691,7 @@ impl ProfileManager {
// Check Wayfern status using WayfernManager
async fn check_wayfern_status(
&self,
_app_handle: &tauri::AppHandle,
app_handle: &tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let manager = self.wayfern_manager;
@@ -1634,10 +1720,14 @@ impl ProfileManager {
};
if latest.process_id != wayfern_process.processId {
let old_pid = latest.process_id;
latest.process_id = wayfern_process.processId;
if let Err(e) = self.save_profile(&latest) {
log::warn!("Warning: Failed to update Wayfern profile with process info: {e}");
}
if let (Some(prev), Some(new)) = (old_pid, wayfern_process.processId) {
let _ = crate::proxy_manager::PROXY_MANAGER.update_proxy_pid(prev, new);
}
// Emit profile update event to frontend
if let Err(e) = events::emit("profile-updated", &latest) {
@@ -1679,6 +1769,12 @@ impl ProfileManager {
log::warn!("Warning: Failed to clear Wayfern profile process info: {e}");
}
if let Some(updated) = crate::auto_updater::AutoUpdater::instance()
.update_profile_to_latest_installed(app_handle, &latest)
{
latest = updated;
}
if let Err(e) = events::emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -1703,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(),
+56 -10
View File
@@ -292,10 +292,45 @@ pub async fn set_profile_password(profile_id: String, password: String) -> Resul
.map_err(err_internal)?;
cache_key(id, key);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed();
Ok(())
}
/// Verify a profile password without unlocking. Used by the Settings UI's
/// "Validate" button so users can confirm they remember the password without
/// performing a destructive change. Honors the same lockout schedule as
/// `unlock_profile` so a brute-force attacker can't bypass rate-limiting by
/// hammering this command.
#[tauri::command]
pub async fn verify_profile_password(profile_id: String, password: String) -> Result<(), String> {
let id = parse_uuid(&profile_id)?;
let profile = load_profile(&id)?;
if !profile.password_protected {
return Err(err_code("PROFILE_NOT_PROTECTED"));
}
if let Err(secs) = check_lockout(&id) {
return Err(err_with("LOCKED_OUT", &[("seconds", secs.to_string())]));
}
let salt = profile
.encryption_salt
.as_deref()
.ok_or_else(|| err_code("PROFILE_MISSING_SALT"))?;
let key = derive_profile_key(&password, salt).map_err(err_internal)?;
let dir = profile_data_dir(&profile);
match verify_key_against_dir(&key, &dir) {
Ok(()) => {
clear_failed_attempts(&id);
Ok(())
}
Err(crate::profile::encryption::PasswordError::WrongPassword) => {
record_failed_attempt(id);
Err(err_code("INCORRECT_PASSWORD"))
}
Err(other) => Err(err_internal(other)),
}
}
#[tauri::command]
pub async fn unlock_profile(profile_id: String, password: String) -> Result<(), String> {
let id = parse_uuid(&profile_id)?;
@@ -396,6 +431,7 @@ pub async fn change_profile_password(
drop_cached_key(&id);
cache_key(id, new_key);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed();
Ok(())
}
@@ -464,6 +500,7 @@ pub async fn remove_profile_password(profile_id: String, password: String) -> Re
.map_err(err_internal)?;
drop_cached_key(&id);
crate::sync::queue_profile_sync_if_eligible(&profile);
emit_profiles_changed();
Ok(())
}
@@ -637,22 +674,31 @@ pub fn complete_after_quit_blocking(
result
}
/// Async re-encrypt of a password-protected profile's ephemeral dir back to
/// disk, called after the browser process exits. Optionally purges the
/// ephemeral dir + cached key based on the global setting.
pub fn complete_after_quit(profile: &crate::profile::BrowserProfile) {
/// Re-encrypt a password-protected profile's ephemeral dir back to the
/// on-disk encrypted dir after the browser process exits. Optionally purges
/// the ephemeral dir + cached key based on the global setting. Returns the
/// number of files re-encrypted (`None` when nothing to do or the profile
/// isn't protected).
///
/// Callers that release a queued sync run after a browser quit MUST await
/// this future — releasing sync while re-encryption is still in-flight
/// uploads the stale on-disk snapshot and leaves the fresh ciphertext
/// orphaned until the next scheduler tick.
pub async fn complete_after_quit_and_wait(
profile: &crate::profile::BrowserProfile,
) -> Option<usize> {
if !profile.password_protected {
return;
return None;
}
let keep_decrypted = read_keep_decrypted_setting();
let profile = profile.clone();
tauri::async_runtime::spawn(async move {
let _ = tokio::task::spawn_blocking(move || {
complete_after_quit_blocking(&profile, keep_decrypted);
tokio::task::spawn_blocking(move || complete_after_quit_blocking(&profile, keep_decrypted))
.await
.unwrap_or_else(|e| {
log::error!("complete_after_quit_and_wait join error: {e}");
None
})
.await;
});
}
#[cfg(test)]
+8
View File
@@ -346,6 +346,14 @@ pub fn check_has_e2e_password() -> bool {
has_e2e_password()
}
#[tauri::command]
pub fn verify_e2e_password(password: String) -> Result<bool, String> {
match load_e2e_password()? {
Some(stored) => Ok(stored == password),
None => Err(serde_json::json!({ "code": "NO_E2E_PASSWORD_SET" }).to_string()),
}
}
#[tauri::command]
pub async fn delete_e2e_password() -> Result<(), String> {
enforce_team_owner_for_encryption_change().await?;
+21 -1
View File
@@ -7,7 +7,9 @@ pub mod subscription;
pub mod types;
pub use client::SyncClient;
pub use encryption::{check_has_e2e_password, delete_e2e_password, set_e2e_password};
pub use encryption::{
check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password,
};
pub use engine::{
enable_extension_group_sync_if_needed, enable_group_sync_if_needed, enable_proxy_sync_if_needed,
enable_sync_for_all_entities, enable_vpn_sync_if_needed, get_unsynced_entity_counts,
@@ -22,3 +24,21 @@ pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, Syn
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
pub use subscription::{SubscriptionManager, SyncWorkItem};
pub use types::{SyncError, SyncResult};
/// Queue a profile sync if the profile has sync enabled. No-op otherwise.
///
/// Called from profile metadata update paths so a rename / tag edit / proxy
/// reassignment shows up on other devices without waiting for the next
/// scheduled tick. Spawns the async queue call so this helper is callable
/// from both sync and async contexts.
pub fn queue_profile_sync_if_eligible(profile: &crate::profile::BrowserProfile) {
if !profile.is_sync_enabled() {
return;
}
let profile_id = profile.id.to_string();
tauri::async_runtime::spawn(async move {
if let Some(scheduler) = get_global_scheduler() {
scheduler.queue_profile_sync(profile_id).await;
}
});
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.24.1",
"version": "0.24.2",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+181 -5
View File
@@ -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 {
@@ -613,7 +756,9 @@ export default function Home() {
wayfernConfig: profileData.wayfernConfig,
groupId:
profileData.groupId ??
(selectedGroupId !== "default" ? selectedGroupId : undefined),
(selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined),
ephemeral: profileData.ephemeral,
dnsBlocklist: profileData.dnsBlocklist,
launchHook: profileData.launchHook,
@@ -1243,11 +1388,10 @@ export default function Home() {
let filtered = profiles;
// Filter by group. "__all__" is a virtual filter that shows every
// profile regardless of group; "default" shows ungrouped profiles.
if (selectedGroupId === "__all__") {
// profile (including ungrouped ones). Any other value is a real
// group id; ungrouped profiles only show through "All".
if (!selectedGroupId || selectedGroupId === "__all__") {
filtered = profiles;
} else if (!selectedGroupId || selectedGroupId === "default") {
filtered = profiles.filter((profile) => !profile.group_id);
} else {
filtered = profiles.filter(
(profile) => profile.group_id === selectedGroupId,
@@ -1292,6 +1436,7 @@ export default function Home() {
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
groups={groupsData}
totalProfiles={profiles.length}
selectedGroupId={selectedGroupId}
onGroupSelect={handleSelectGroup}
pageTitle={subPageTitle}
@@ -1304,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}
@@ -1342,6 +1489,10 @@ export default function Home() {
</div>
)}
{currentPage === "shortcuts" && (
<ShortcutsPage groupTargets={orderedGroupTargets} />
)}
{settingsDialogOpen && (
<SettingsDialog
isOpen={settingsDialogOpen}
@@ -1366,6 +1517,7 @@ export default function Home() {
setCurrentPage("profiles");
}}
subPage={currentPage === "integrations"}
initialTab={integrationsInitialTab}
/>
)}
@@ -1402,6 +1554,7 @@ export default function Home() {
}}
limitedMode={false}
subPage={currentPage === "extensions"}
initialTab={extensionManagementInitialTab}
/>
)}
@@ -1445,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}
+25 -39
View File
@@ -12,16 +12,20 @@ import {
LuUser,
} from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { SyncSettings } from "@/types";
interface AccountPageProps {
@@ -194,25 +198,12 @@ export function AccountPage({
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="max-w-2xl flex flex-col">
<div className="flex flex-col gap-4 p-4">
<Tabs defaultValue="account">
<TabsList
className={cn(
"w-full",
subPage &&
"!bg-transparent !p-0 !h-auto !rounded-none justify-start gap-4",
)}
>
<TabsTrigger
value="account"
className={cn(
"flex-1",
subPage &&
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs",
)}
>
<AnimatedTabs defaultValue="account">
<AnimatedTabsList>
<AnimatedTabsTrigger value="account">
{t("account.tabs.account")}
</TabsTrigger>
<TabsTrigger
</AnimatedTabsTrigger>
<AnimatedTabsTrigger
value="self-hosted"
disabled={selfHostedDisabled}
title={
@@ -220,21 +211,16 @@ export function AccountPage({
? t("account.selfHosted.disabledWhileLoggedIn")
: undefined
}
className={cn(
"flex-1",
subPage &&
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs disabled:opacity-50 disabled:hover:text-muted-foreground",
)}
>
{t("account.tabs.selfHosted")}
</TabsTrigger>
</TabsList>
</AnimatedTabsTrigger>
</AnimatedTabsList>
<TabsContent value="account" className="mt-4">
<AnimatedTabsContent value="account" className="mt-4">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="grid place-items-center w-12 h-12 rounded-full bg-accent text-foreground shrink-0">
<LuUser className="w-6 h-6" />
<div className="grid place-items-center size-12 rounded-full bg-accent text-foreground shrink-0">
<LuUser className="size-6" />
</div>
<div className="min-w-0 flex-1">
{isLoggedIn && user ? (
@@ -309,7 +295,7 @@ export function AccountPage({
disabled={isRefreshing}
className="h-8 text-xs gap-1.5"
>
<LuRefreshCw className="w-3 h-3" />
<LuRefreshCw className="size-3" />
{t("account.refresh")}
</Button>
<LoadingButton
@@ -322,7 +308,7 @@ export function AccountPage({
}}
className="h-8 text-xs gap-1.5"
>
<LuLogOut className="w-3 h-3" />
<LuLogOut className="size-3" />
{t("account.logout")}
</LoadingButton>
</>
@@ -332,15 +318,15 @@ export function AccountPage({
onClick={onOpenSignIn}
className="h-8 text-xs gap-1.5"
>
<LuCloud className="w-3 h-3" />
<LuCloud className="size-3" />
{t("account.signIn")}
</Button>
)}
</div>
</div>
</TabsContent>
</AnimatedTabsContent>
<TabsContent value="self-hosted" className="mt-4">
<AnimatedTabsContent value="self-hosted" className="mt-4">
{selfHostedDisabled ? (
// Defensive: the tab trigger is disabled while the user is
// logged in, so this branch shouldn't be reachable via UI —
@@ -410,9 +396,9 @@ export function AccountPage({
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
>
{showToken ? (
<LuEyeOff className="w-3.5 h-3.5" />
<LuEyeOff className="size-3.5" />
) : (
<LuEye className="w-3.5 h-3.5" />
<LuEye className="size-3.5" />
)}
</button>
</div>
@@ -481,8 +467,8 @@ export function AccountPage({
</div>
</div>
)}
</TabsContent>
</Tabs>
</AnimatedTabsContent>
</AnimatedTabs>
</div>
</DialogContent>
</Dialog>
+5 -5
View File
@@ -37,7 +37,7 @@ export function AppUpdateToast({
return (
<div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
<div className="mr-3 mt-0.5">
<LuCheckCheck className="flex-shrink-0 w-5 h-5" />
<LuCheckCheck className="flex-shrink-0 size-5" />
</div>
<div className="flex-1 min-w-0">
@@ -59,9 +59,9 @@ export function AppUpdateToast({
variant="ghost"
size="sm"
onClick={onDismiss}
className="p-0 w-6 h-6 shrink-0"
className="p-0 size-6 shrink-0"
>
<FaTimes className="w-3 h-3" />
<FaTimes className="size-3" />
</Button>
</div>
@@ -72,7 +72,7 @@ export function AppUpdateToast({
size="sm"
className="flex gap-2 items-center text-xs"
>
<LuCheckCheck className="w-3 h-3" />
<LuCheckCheck className="size-3" />
{t("appUpdate.toast.restartNow")}
</RippleButton>
) : (
@@ -83,7 +83,7 @@ export function AppUpdateToast({
size="sm"
className="flex gap-2 items-center text-xs"
>
<FaExternalLinkAlt className="w-3 h-3" />
<FaExternalLinkAlt className="size-3" />
{t("appUpdate.toast.viewRelease")}
</RippleButton>
)
+10 -8
View File
@@ -36,16 +36,18 @@ export function CloneProfileDialog({
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (isOpen && profile) {
const defaultName = `${profile.name} (Copy)`;
setName(defaultName);
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
} else {
if (!(isOpen && profile)) {
setIsLoading(false);
return;
}
setName(`${profile.name} (Copy)`);
const handle = window.setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
return () => {
window.clearTimeout(handle);
};
}, [isOpen, profile]);
if (!profile) return null;
+275
View File
@@ -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>
);
}
+6 -6
View File
@@ -335,7 +335,7 @@ export function CookieCopyDialog({
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuCookie className="w-5 h-5" />
<LuCookie className="size-5" />
{t("cookies.copy.title")}
</DialogTitle>
<DialogDescription>
@@ -372,7 +372,7 @@ export function CookieCopyDialog({
disabled={isRunning}
>
<div className="flex items-center gap-2">
{IconComponent && <IconComponent className="w-4 h-4" />}
{IconComponent && <IconComponent className="size-4" />}
<span>{profile.name}</span>
{isRunning && (
<span className="text-xs text-muted-foreground">
@@ -437,7 +437,7 @@ export function CookieCopyDialog({
</div>
<div className="relative">
<LuSearch className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<LuSearch className="absolute left-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder={t("cookies.copy.searchPlaceholder")}
value={searchQuery}
@@ -450,7 +450,7 @@ export function CookieCopyDialog({
{isLoadingCookies ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
<div className="animate-spin size-6 border-2 border-primary border-t-transparent rounded-full" />
</div>
) : error ? (
<div className="p-4 text-center text-destructive bg-destructive/10 rounded-md">
@@ -565,9 +565,9 @@ function DomainRow({
}}
>
{isExpanded ? (
<LuChevronDown className="w-4 h-4" />
<LuChevronDown className="size-4" />
) : (
<LuChevronRight className="w-4 h-4" />
<LuChevronRight className="size-4" />
)}
<span className="font-medium">{domain.domain}</span>
<span className="text-xs text-muted-foreground">
+7 -7
View File
@@ -15,9 +15,9 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { Label } from "@/components/ui/label";
import { RippleButton } from "@/components/ui/ripple";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
@@ -429,7 +429,7 @@ export function CookieManagementDialog({
}
}}
>
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<LuUpload className="size-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center">
{t("cookies.management.dropPrompt")}
<br />
@@ -556,14 +556,14 @@ export function CookieManagementDialog({
{isLoadingExportCookies ? (
<div className="flex items-center justify-center h-24">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" />
<div className="animate-spin size-5 border-2 border-primary border-t-transparent rounded-full" />
</div>
) : !exportCookieData || exportCookieData.domains.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground border rounded-md">
{t("cookies.management.noCookies")}
</div>
) : (
<ScrollArea className="h-[200px] border rounded-md">
<FadingScrollArea className="h-[200px]">
<div className="p-2 space-y-1">
{exportCookieData.domains.map((domain) => (
<ExportDomainRow
@@ -577,7 +577,7 @@ export function CookieManagementDialog({
/>
))}
</div>
</ScrollArea>
</FadingScrollArea>
)}
</div>
@@ -645,9 +645,9 @@ function ExportDomainRow({
}}
>
{isExpanded ? (
<LuChevronDown className="w-3.5 h-3.5" />
<LuChevronDown className="size-3.5" />
) : (
<LuChevronRight className="w-3.5 h-3.5" />
<LuChevronRight className="size-3.5" />
)}
<span className="font-medium truncate">{domain.domain}</span>
<span className="text-xs text-muted-foreground shrink-0">
+46 -26
View File
@@ -1,7 +1,14 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
@@ -116,6 +123,8 @@ export function CreateProfileDialog({
crossOsUnlocked = false,
}: CreateProfileDialogProps) {
const { t } = useTranslation();
const proxyListboxIdAntiDetect = useId();
const proxyListboxIdRegular = useId();
const [profileName, setProfileName] = useState("");
const [currentStep, setCurrentStep] = useState<
"browser-selection" | "browser-config"
@@ -422,7 +431,9 @@ export function CreateProfileDialog({
vpnId: resolvedVpnId,
wayfernConfig: finalWayfernConfig,
groupId:
selectedGroupId !== "default" ? selectedGroupId : undefined,
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
@@ -450,7 +461,9 @@ export function CreateProfileDialog({
vpnId: resolvedVpnId,
camoufoxConfig: finalCamoufoxConfig,
groupId:
selectedGroupId !== "default" ? selectedGroupId : undefined,
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
extensionGroupId: selectedExtensionGroupId,
ephemeral,
dnsBlocklist: dnsBlocklist || undefined,
@@ -478,7 +491,10 @@ export function CreateProfileDialog({
version: bestVersion.version,
releaseType: bestVersion.releaseType,
proxyId: selectedProxyId,
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
groupId:
selectedGroupId && selectedGroupId !== "__all__"
? selectedGroupId
: undefined,
dnsBlocklist: dnsBlocklist || undefined,
launchHook: launchHook.trim() || undefined,
password: passwordToSet,
@@ -605,11 +621,11 @@ export function CreateProfileDialog({
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center w-8 h-8">
<div className="flex justify-center items-center size-8">
{(() => {
const IconComponent = getBrowserIcon("wayfern");
return IconComponent ? (
<IconComponent className="w-6 h-6" />
<IconComponent className="size-6" />
) : null;
})()}
</div>
@@ -631,11 +647,11 @@ export function CreateProfileDialog({
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center w-8 h-8">
<div className="flex justify-center items-center size-8">
{(() => {
const IconComponent = getBrowserIcon("camoufox");
return IconComponent ? (
<IconComponent className="w-6 h-6" />
<IconComponent className="size-6" />
) : null;
})()}
</div>
@@ -676,9 +692,9 @@ export function CreateProfileDialog({
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
variant="outline"
>
<div className="flex justify-center items-center w-8 h-8">
<div className="flex justify-center items-center size-8">
{IconComponent && (
<IconComponent className="w-6 h-6" />
<IconComponent className="size-6" />
)}
</div>
<div className="text-left">
@@ -729,7 +745,7 @@ export function CreateProfileDialog({
{/* Ephemeral Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="ephemeral"
checked={ephemeral}
@@ -749,7 +765,7 @@ export function CreateProfileDialog({
{/* Password Option */}
{!ephemeral && (
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="enable-password"
checked={enablePassword}
@@ -814,7 +830,7 @@ export function CreateProfileDialog({
{/* Wayfern Download Status */}
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
{t("createProfile.version.fetching")}
</p>
@@ -922,7 +938,7 @@ export function CreateProfileDialog({
{/* Camoufox Download Status */}
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center p-3 rounded-md border">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
{t("createProfile.version.fetching")}
</p>
@@ -1041,7 +1057,7 @@ export function CreateProfileDialog({
<div className="space-y-3">
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
{t("createProfile.version.fetching")}
</p>
@@ -1154,7 +1170,7 @@ export function CreateProfileDialog({
}}
className="px-2 h-7 text-xs"
>
<GoPlus className="mr-1 w-3 h-3" />{" "}
<GoPlus className="mr-1 size-3" />{" "}
{t("createProfile.proxy.addProxy")}
</RippleButton>
</div>
@@ -1168,6 +1184,7 @@ export function CreateProfileDialog({
variant="outline"
role="combobox"
aria-expanded={proxyPopoverOpen}
aria-controls={proxyListboxIdAntiDetect}
className="w-full justify-between font-normal"
>
{(() => {
@@ -1190,10 +1207,11 @@ export function CreateProfileDialog({
t("createProfile.proxy.noProxy")
);
})()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
id={proxyListboxIdAntiDetect}
className="w-[240px] p-0"
sideOffset={8}
>
@@ -1217,7 +1235,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
!selectedProxyId
? "opacity-100"
: "opacity-0",
@@ -1236,7 +1254,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedProxyId === proxy.id
? "opacity-100"
: "opacity-0",
@@ -1261,7 +1279,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedProxyId ===
`vpn-${vpn.id}`
? "opacity-100"
@@ -1412,7 +1430,7 @@ export function CreateProfileDialog({
<div className="space-y-3">
{isLoadingReleaseTypes && (
<div className="flex gap-3 items-center">
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
<p className="text-sm text-muted-foreground">
Fetching available versions...
</p>
@@ -1520,7 +1538,7 @@ export function CreateProfileDialog({
}}
className="px-2 h-7 text-xs"
>
<GoPlus className="mr-1 w-3 h-3" />{" "}
<GoPlus className="mr-1 size-3" />{" "}
{t("createProfile.proxy.addProxy")}
</RippleButton>
</div>
@@ -1534,6 +1552,7 @@ export function CreateProfileDialog({
variant="outline"
role="combobox"
aria-expanded={proxyPopoverOpen}
aria-controls={proxyListboxIdRegular}
className="w-full justify-between font-normal"
>
{(() => {
@@ -1556,10 +1575,11 @@ export function CreateProfileDialog({
t("createProfile.proxy.noProxy")
);
})()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
id={proxyListboxIdRegular}
className="w-[240px] p-0"
sideOffset={8}
>
@@ -1583,7 +1603,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
!selectedProxyId
? "opacity-100"
: "opacity-0",
@@ -1602,7 +1622,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedProxyId === proxy.id
? "opacity-100"
: "opacity-0",
@@ -1627,7 +1647,7 @@ export function CreateProfileDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedProxyId ===
`vpn-${vpn.id}`
? "opacity-100"
+12 -12
View File
@@ -174,42 +174,42 @@ function formatEtaCompact(seconds: number): string {
function getToastIcon(type: ToastProps["type"], stage?: string) {
switch (type) {
case "success":
return <LuCheckCheck className="flex-shrink-0 w-4 h-4 text-foreground" />;
return <LuCheckCheck className="flex-shrink-0 size-4 text-foreground" />;
case "error":
return (
<LuTriangleAlert className="flex-shrink-0 w-4 h-4 text-foreground" />
<LuTriangleAlert className="flex-shrink-0 size-4 text-foreground" />
);
case "download":
if (stage === "completed") {
return (
<LuCheckCheck className="flex-shrink-0 w-4 h-4 text-foreground" />
<LuCheckCheck className="flex-shrink-0 size-4 text-foreground" />
);
}
return <LuDownload className="flex-shrink-0 w-4 h-4 text-foreground" />;
return <LuDownload className="flex-shrink-0 size-4 text-foreground" />;
case "version-update":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
);
case "fetching":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
);
case "twilight-update":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
);
case "sync-progress":
return (
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
);
case "loading":
return (
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
<div className="flex-shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
);
default:
return (
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
<div className="flex-shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
);
}
}
@@ -235,7 +235,7 @@ export function UnifiedToast(props: ToastProps) {
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
aria-label={t("common.buttons.cancel")}
>
<LuX className="w-3 h-3" />
<LuX className="size-3" />
</button>
)}
</div>
@@ -272,7 +272,7 @@ export function UnifiedToast(props: ToastProps) {
<>Looking for updates for {progress.current_browser}</>
)}
</p>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<div className="flex-1 bg-muted rounded-full h-1.5 min-w-0">
<div
className="bg-foreground h-1.5 rounded-full transition-all duration-150"
+1 -1
View File
@@ -106,7 +106,7 @@ function DataTableActionBarAction({
{...props}
>
{isPending ? (
<div className="w-3.5 h-3.5 rounded-full border border-current animate-spin border-t-transparent" />
<div className="size-3.5 rounded-full border border-current animate-spin border-t-transparent" />
) : (
children
)}
+2 -2
View File
@@ -155,13 +155,13 @@ export function DeleteGroupDialog({
setDeleteAction(value as "move" | "delete");
}}
>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<RadioGroupItem value="move" id="move" />
<Label htmlFor="move" className="text-sm">
{t("groups.moveToDefault")}
</Label>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<RadioGroupItem value="delete" id="delete" />
<Label
htmlFor="delete"
+1 -1
View File
@@ -105,7 +105,7 @@ export function DeviceCodeVerifyDialog({
disabled={isOpeningLogin}
className="w-full gap-1.5"
>
<LuExternalLink className="w-3.5 h-3.5" />
<LuExternalLink className="size-3.5" />
{t("sync.cloud.openLogin")}
</Button>
<div className="space-y-2">
+1 -1
View File
@@ -137,7 +137,7 @@ export function DnsBlocklistDialog({
className="w-full"
>
<LuRefreshCw
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
className={`mr-2 size-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("dnsBlocklist.refreshAll")}
</Button>
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -77,7 +77,7 @@ export function GroupAssignmentDialog({
const groupName = selectedGroupId
? groups.find((g) => g.id === selectedGroupId)?.name ||
t("groups.unknownGroup")
: t("groups.defaultGroup");
: t("groups.noGroup");
toast.success(
t("groups.assignSuccess", {
@@ -165,7 +165,7 @@ export function GroupAssignmentDialog({
setCreateDialogOpen(true);
}}
>
<GoPlus className="mr-1 w-3 h-3" />{" "}
<GoPlus className="mr-1 size-3" />{" "}
{t("groupManagement.createGroup")}
</RippleButton>
</div>
@@ -175,17 +175,17 @@ export function GroupAssignmentDialog({
</div>
) : (
<Select
value={selectedGroupId ?? "default"}
value={selectedGroupId ?? "__none__"}
onValueChange={(value) => {
setSelectedGroupId(value === "default" ? null : value);
setSelectedGroupId(value === "__none__" ? null : value);
}}
>
<SelectTrigger>
<SelectValue placeholder={t("groupAssignment.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
{t("groups.defaultGroupNoGroup")}
<SelectItem value="__none__">
{t("groups.noGroup")}
</SelectItem>
{groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
+1 -3
View File
@@ -183,9 +183,7 @@ export function GroupBadges({
}
}}
>
<span>
{group.id === "default" ? t("groups.defaultGroup") : group.name}
</span>
<span>{group.name}</span>
<span className="bg-background/20 text-xs px-1.5 py-0.5 rounded-sm">
{group.count}
</span>
+405 -134
View File
@@ -1,14 +1,37 @@
"use client";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
type RowSelectionState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuPencil, LuTrash2 } from "react-icons/lu";
import {
LuChevronDown,
LuChevronUp,
LuFolder,
LuPencil,
LuRefreshCw,
LuTrash2,
} from "react-icons/lu";
import { CreateGroupDialog } from "@/components/create-group-dialog";
import {
DataTableActionBar,
DataTableActionBarAction,
DataTableActionBarSelection,
} from "@/components/data-table-action-bar";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { DeleteGroupDialog } from "@/components/delete-group-dialog";
import { EditGroupDialog } from "@/components/edit-group-dialog";
import { AnimatedSwitch } from "@/components/ui/animated-switch";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -20,8 +43,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import {
Table,
TableBody,
@@ -111,6 +133,8 @@ export function GroupManagementDialog({
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [selectedGroup, setSelectedGroup] = useState<GroupWithCount | null>(
null,
);
@@ -125,6 +149,12 @@ export function GroupManagementDialog({
{},
);
// Table state
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
// Listen for group sync status events
useEffect(() => {
let unlisten: (() => void) | undefined;
@@ -246,9 +276,272 @@ export function GroupManagementDialog({
useEffect(() => {
if (isOpen) {
void loadGroups();
} else {
// Drop any selection when the dialog closes so the floating
// action bar (portaled to body) doesn't linger on the page.
setRowSelection({});
}
}, [isOpen, loadGroups]);
const columns = useMemo<ColumnDef<GroupWithCount>[]>(
() => [
{
id: "select",
size: 36,
enableSorting: false,
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(value) => {
table.toggleAllRowsSelected(!!value);
}}
aria-label={t("common.aria.selectAll")}
disabled={table.getRowModel().rows.length === 0}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => {
row.toggleSelected(!!value);
}}
aria-label={t("common.aria.selectRow")}
/>
),
},
{
accessorKey: "name",
enableSorting: true,
sortingFn: "alphanumeric",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => {
column.toggleSorting(column.getIsSorted() === "asc");
}}
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
>
{t("common.labels.name")}
{column.getIsSorted() === "asc" ? (
<LuChevronUp className="ml-2 size-4" />
) : column.getIsSorted() === "desc" ? (
<LuChevronDown className="ml-2 size-4" />
) : null}
</Button>
),
cell: ({ row }) => {
const group = row.original;
const syncDot = getSyncStatusDot(
group,
groupSyncStatus[group.id],
t,
groupSyncErrors[group.id],
);
return (
<div className="flex items-center gap-2 font-medium">
<Tooltip>
<TooltipTrigger asChild>
<div
className={`size-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate ? "animate-pulse" : ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
<LuFolder className="size-4 text-muted-foreground" />
{group.name}
</div>
);
},
},
{
id: "count",
size: 80,
enableSorting: false,
header: () => t("groupManagement.profilesCol"),
cell: ({ row }) => (
<Badge variant="secondary">{row.original.count}</Badge>
),
},
{
id: "sync",
size: 96,
enableSorting: false,
header: () => t("proxies.management.syncCol"),
cell: ({ row }) => {
const group = row.original;
const locked = groupInUse[group.id];
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center">
<AnimatedSwitch
checked={group.sync_enabled}
onCheckedChange={() => handleToggleSync(group)}
disabled={isTogglingSync[group.id] || locked}
/>
</span>
</TooltipTrigger>
<TooltipContent>
{locked ? (
<p>{t("syncTooltips.lockedInUse")}</p>
) : (
<p>
{group.sync_enabled
? t("syncTooltips.disable")
: t("syncTooltips.enable")}
</p>
)}
</TooltipContent>
</Tooltip>
);
},
},
{
id: "actions",
size: 96,
enableSorting: false,
header: () => t("common.labels.actions"),
cell: ({ row }) => {
const group = row.original;
return (
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleEditGroup(group);
}}
>
<LuPencil className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("groupManagement.editGroupTooltip")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleDeleteGroup(group);
}}
>
<LuTrash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("groupManagement.deleteGroupTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
);
},
},
],
[
t,
groupSyncStatus,
groupSyncErrors,
groupInUse,
isTogglingSync,
handleToggleSync,
handleEditGroup,
handleDeleteGroup,
],
);
const table = useReactTable({
data: groups,
columns,
state: { sorting, rowSelection },
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
});
const selectedRows = table.getFilteredSelectedRowModel().rows;
const selectedGroupsForBulk = useMemo(
() => selectedRows.map((row) => row.original),
[selectedRows],
);
const selectedNames = useMemo(
() => selectedGroupsForBulk.map((g) => g.name).join(", "),
[selectedGroupsForBulk],
);
const handleBulkDelete = useCallback(async () => {
if (selectedGroupsForBulk.length === 0) return;
setIsBulkDeleting(true);
try {
const ids = selectedGroupsForBulk.map((g) => g.id);
const results = await Promise.allSettled(
ids.map((groupId) => invoke("delete_profile_group", { groupId })),
);
const failed = results.filter((r) => r.status === "rejected");
if (failed.length > 0) {
showErrorToast(t("groups.deleteFailed"));
} else {
showSuccessToast(t("groups.deleteSuccess"));
}
table.toggleAllRowsSelected(false);
setBulkDeleteOpen(false);
await loadGroups();
onGroupManagementComplete();
} catch (err) {
console.error("Bulk group delete failed:", err);
showErrorToast(
err instanceof Error ? err.message : t("groups.deleteFailed"),
);
} finally {
setIsBulkDeleting(false);
}
}, [selectedGroupsForBulk, table, loadGroups, onGroupManagementComplete, t]);
const handleBulkToggleSync = useCallback(async () => {
if (selectedGroupsForBulk.length === 0) return;
const allOn = selectedGroupsForBulk.every((g) => g.sync_enabled);
const targetEnabled = !allOn;
const targets = selectedGroupsForBulk.filter((g) =>
targetEnabled ? !g.sync_enabled : g.sync_enabled && !groupInUse[g.id],
);
if (targets.length === 0) return;
const results = await Promise.allSettled(
targets.map((group) =>
invoke("set_group_sync_enabled", {
groupId: group.id,
enabled: targetEnabled,
}),
),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) {
showErrorToast(t("proxies.management.updateSyncFailed"));
} else {
showSuccessToast(
targetEnabled
? t("proxies.management.syncEnabled")
: t("proxies.management.syncDisabled"),
);
}
await loadGroups();
}, [selectedGroupsForBulk, groupInUse, loadGroups, t]);
return (
<>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
@@ -262,18 +555,24 @@ export function GroupManagementDialog({
</DialogHeader>
)}
<div className="space-y-4">
{/* Create new group button */}
<div className="flex justify-between items-center">
<Label>{t("groupManagement.groupsLabel")}</Label>
<div className="flex flex-col gap-4 flex-1 min-h-0">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<h2 className="text-base font-semibold">
{t("groups.pageTitle")}
</h2>
<p className="text-xs text-muted-foreground">
{t("groups.pageDescription")}
</p>
</div>
<RippleButton
size="sm"
onClick={() => {
setCreateDialogOpen(true);
}}
className="flex gap-2 items-center"
className="flex gap-2 items-center shrink-0"
>
<GoPlus className="w-4 h-4" />
<GoPlus className="size-4" />
{t("proxies.management.create")}
</RippleButton>
</div>
@@ -294,131 +593,64 @@ export function GroupManagementDialog({
{t("groups.noGroupsDescription")}
</div>
) : (
<div className="border rounded-md">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("common.labels.name")}</TableHead>
<TableHead className="w-20">
{t("groupManagement.profilesCol")}
</TableHead>
<TableHead className="w-24">
{t("proxies.management.syncCol")}
</TableHead>
<TableHead className="w-24">
{t("common.labels.actions")}
</TableHead>
<FadingScrollArea
className="flex-1 min-h-0"
style={
{
"--scroll-fade-top-offset": "32px",
} as React.CSSProperties
}
>
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{
width: header.column.columnDef.size
? `${header.column.getSize()}px`
: undefined,
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => {
const syncDot = getSyncStatusDot(
group,
groupSyncStatus[group.id],
t,
groupSyncErrors[group.id],
);
return (
<TableRow key={group.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate ? "animate-pulse" : ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
{group.name}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">{group.count}</Badge>
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={group.sync_enabled}
onCheckedChange={() =>
handleToggleSync(group)
}
disabled={
isTogglingSync[group.id] ||
groupInUse[group.id]
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{groupInUse[group.id] ? (
<p>
{t("groupManagement.syncCannotDisable")}
</p>
) : (
<p>
{group.sync_enabled
? t("proxies.management.disableSync")
: t("proxies.management.enableSync")}
</p>
)}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleEditGroup(group);
}}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{t("groupManagement.editGroupTooltip")}
</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleDeleteGroup(group);
}}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{t("groupManagement.deleteGroupTooltip")}
</p>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</div>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: cell.column.columnDef.size
? `${cell.column.getSize()}px`
: undefined,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</FadingScrollArea>
)}
</div>
@@ -432,6 +664,45 @@ export function GroupManagementDialog({
</DialogContent>
</Dialog>
{isOpen && (
<DataTableActionBar table={table}>
<DataTableActionBarSelection table={table} />
<DataTableActionBarAction
tooltip={t("syncTooltips.bulkToggle")}
onClick={() => {
void handleBulkToggleSync();
}}
size="icon"
>
<LuRefreshCw />
</DataTableActionBarAction>
<DataTableActionBarAction
tooltip={t("common.buttons.delete")}
onClick={() => setBulkDeleteOpen(true)}
size="icon"
variant="destructive"
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
>
<LuTrash2 />
</DataTableActionBarAction>
</DataTableActionBar>
)}
<DeleteConfirmationDialog
isOpen={bulkDeleteOpen}
onClose={() => {
if (!isBulkDeleting) setBulkDeleteOpen(false);
}}
onConfirm={handleBulkDelete}
title={t("groupManagement.bulkDelete.title")}
description={t("groupManagement.bulkDelete.description", {
count: selectedGroupsForBulk.length,
names: selectedNames,
})}
confirmButtonText={t("groupManagement.bulkDelete.confirmButton")}
isLoading={isBulkDeleting}
/>
<CreateGroupDialog
isOpen={createDialogOpen}
onClose={() => {
+11 -16
View File
@@ -1,7 +1,7 @@
"use client";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuChevronLeft, LuChevronRight, LuSearch, LuX } from "react-icons/lu";
@@ -30,6 +30,7 @@ interface Props {
searchQuery: string;
onSearchQueryChange: (query: string) => void;
groups: GroupWithCount[];
totalProfiles: number;
selectedGroupId: string | null;
onGroupSelect: (groupId: string) => void;
pageTitle?: string;
@@ -40,6 +41,7 @@ const HomeHeader = ({
searchQuery,
onSearchQueryChange,
groups,
totalProfiles,
selectedGroupId,
onGroupSelect,
pageTitle,
@@ -54,11 +56,6 @@ const HomeHeader = ({
const isMacOS = platform === "macos";
const showProfileToolbar = !pageTitle;
const totalProfiles = useMemo(
() => groups.reduce((sum, g) => sum + g.count, 0),
[groups],
);
// Press-and-hold drag: any pixel of the sys-bar becomes a drag handle after
// HOLD_MS, but quick clicks still reach buttons/inputs underneath.
const holdTimeoutRef = useRef<number | null>(null);
@@ -208,9 +205,9 @@ const HomeHeader = ({
behavior: "smooth",
});
}}
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-5 h-5 rounded-full bg-card/90 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors shadow-sm"
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 grid place-items-center size-5 rounded-full bg-card/90 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors shadow-sm"
>
<LuChevronLeft className="w-3 h-3" />
<LuChevronLeft className="size-3" />
</button>
)}
<div
@@ -247,8 +244,6 @@ const HomeHeader = ({
})()}
{groups.map((group) => {
const active = selectedGroupId === group.id;
const label =
group.id === "default" ? t("groups.defaultGroup") : group.name;
return (
<button
key={group.id}
@@ -263,7 +258,7 @@ const HomeHeader = ({
: "text-muted-foreground hover:text-foreground",
)}
>
<span>{label}</span>
<span>{group.name}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{group.count}
</span>
@@ -283,9 +278,9 @@ const HomeHeader = ({
behavior: "smooth",
});
}}
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-5 h-5 rounded-full bg-card/90 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors shadow-sm"
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 grid place-items-center size-5 rounded-full bg-card/90 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors shadow-sm"
>
<LuChevronRight className="w-3 h-3" />
<LuChevronRight className="size-3" />
</button>
)}
</div>
@@ -304,7 +299,7 @@ const HomeHeader = ({
}}
className="pr-7 pl-8 w-52 h-7 text-xs"
/>
<LuSearch className="absolute left-2.5 top-1/2 w-3.5 h-3.5 transform -translate-y-1/2 text-muted-foreground pointer-events-none" />
<LuSearch className="absolute left-2.5 top-1/2 size-3.5 transform -translate-y-1/2 text-muted-foreground pointer-events-none" />
{searchQuery ? (
<button
type="button"
@@ -314,7 +309,7 @@ const HomeHeader = ({
className="absolute right-1.5 top-1/2 p-0.5 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
aria-label={t("header.clearSearch")}
>
<LuX className="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
<LuX className="size-3.5 text-muted-foreground hover:text-foreground" />
</button>
) : null}
</div>
@@ -331,7 +326,7 @@ const HomeHeader = ({
}}
className="flex gap-1.5 items-center h-7 px-2.5 text-xs"
>
<GoPlus className="w-3.5 h-3.5" />
<GoPlus className="size-3.5" />
{t("header.newProfile")}
</Button>
</span>
+27 -29
View File
@@ -9,6 +9,12 @@ import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -304,31 +310,23 @@ export function ImportProfileDialog({
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
{currentStep === "select" && (
<>
<div className="flex gap-2">
<RippleButton
variant={importMode === "auto-detect" ? "default" : "outline"}
onClick={() => {
setImportMode("auto-detect");
}}
className="flex-1"
disabled={isLoading}
>
<AnimatedTabs
value={importMode}
onValueChange={(v) =>
setImportMode(v as "auto-detect" | "manual")
}
className="flex flex-col gap-6"
>
<AnimatedTabsList>
<AnimatedTabsTrigger value="auto-detect" disabled={isLoading}>
{t("importProfile.autoDetect")}
</RippleButton>
<RippleButton
variant={importMode === "manual" ? "default" : "outline"}
onClick={() => {
setImportMode("manual");
}}
className="flex-1"
disabled={isLoading}
>
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="manual" disabled={isLoading}>
{t("importProfile.manualImport")}
</RippleButton>
</div>
</AnimatedTabsTrigger>
</AnimatedTabsList>
{importMode === "auto-detect" && (
<AnimatedTabsContent value="auto-detect">
<div className="space-y-4">
<h3 className="text-lg font-medium">
{t("importProfile.detectedProfilesTitle")}
@@ -383,7 +381,7 @@ export function ImportProfileDialog({
>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
<IconComponent className="size-4" />
)}
<div className="flex flex-col">
<span className="font-medium">
@@ -439,9 +437,9 @@ export function ImportProfileDialog({
</div>
)}
</div>
)}
</AnimatedTabsContent>
{importMode === "manual" && (
<AnimatedTabsContent value="manual">
<div className="space-y-4">
<h3 className="text-lg font-medium">
{t("importProfile.manualTitle")}
@@ -475,7 +473,7 @@ export function ImportProfileDialog({
<SelectItem key={browser} value={browser}>
<div className="flex gap-2 items-center">
{IconComponent && (
<IconComponent className="w-4 h-4" />
<IconComponent className="size-4" />
)}
<span>{getBrowserDisplayName(browser)}</span>
</div>
@@ -507,7 +505,7 @@ export function ImportProfileDialog({
onClick={() => void handleBrowseFolder()}
title={t("importProfile.browseFolderTitle")}
>
<FaFolder className="w-4 h-4" />
<FaFolder className="size-4" />
</Button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
@@ -539,8 +537,8 @@ export function ImportProfileDialog({
</div>
</div>
</div>
)}
</>
</AnimatedTabsContent>
</AnimatedTabs>
)}
{currentStep === "configure" && currentMappedBrowser && (
+389 -287
View File
@@ -4,8 +4,23 @@ import { invoke } from "@tauri-apps/api/core";
import { Eye, EyeOff } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
LuAppWindow,
LuCheck,
LuCodeXml,
LuPlug,
LuTerminal,
LuTrash2,
LuZap,
} from "react-icons/lu";
import { AnimatedSwitch } from "@/components/ui/animated-switch";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -14,8 +29,8 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { CopyToClipboard } from "./ui/copy-to-clipboard";
@@ -33,16 +48,59 @@ interface McpConfig {
token: string;
}
type AgentCategory = "desktop-app" | "cli" | "editor" | "editor-ext";
interface McpAgentInfo {
id: string;
display_name: string;
category: AgentCategory;
connected: boolean;
detected: boolean;
}
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 }) {
const className = "size-4 text-muted-foreground";
switch (category) {
case "desktop-app":
return <LuAppWindow className={className} />;
case "editor":
return <LuCodeXml className={className} />;
case "editor-ext":
return <LuPlug className={className} />;
case "cli":
return <LuTerminal className={className} />;
}
}
function categoryLabel(
t: (k: string) => string,
category: AgentCategory,
): string {
switch (category) {
case "desktop-app":
return t("integrations.mcp.category.desktopApp");
case "editor":
return t("integrations.mcp.category.editor");
case "editor-ext":
return t("integrations.mcp.category.editorExt");
case "cli":
return t("integrations.mcp.category.cli");
}
}
export function IntegrationsDialog({
isOpen,
onClose,
subPage,
initialTab = "api",
}: IntegrationsDialogProps) {
const { t } = useTranslation();
const [settings, setSettings] = useState<AppSettings>({
@@ -57,11 +115,11 @@ export function IntegrationsDialog({
const [mcpConfig, setMcpConfig] = useState<McpConfig | null>(null);
const [, setMcpRunning] = useState(false);
const [showApiToken, setShowApiToken] = useState(false);
const [showMcpToken, setShowMcpToken] = useState(false);
const [showMcpUrl, setShowMcpUrl] = useState(false);
const [isApiStarting, setIsApiStarting] = useState(false);
const [isMcpStarting, setIsMcpStarting] = useState(false);
const [mcpInClaudeDesktop, setMcpInClaudeDesktop] = useState(false);
const [mcpInClaudeCode, setMcpInClaudeCode] = useState(false);
const [agents, setAgents] = useState<McpAgentInfo[]>([]);
const [busyAgentIds, setBusyAgentIds] = useState<Set<string>>(new Set());
const { termsAccepted } = useWayfernTerms();
@@ -101,21 +159,12 @@ export function IntegrationsDialog({
}
}, []);
const loadClaudeDesktopStatus = useCallback(async () => {
const loadAgents = useCallback(async () => {
try {
const exists = await invoke<boolean>("is_mcp_in_claude_desktop");
setMcpInClaudeDesktop(exists);
} catch {
// Not critical
}
}, []);
const loadClaudeCodeStatus = useCallback(async () => {
try {
const exists = await invoke<boolean>("is_mcp_in_claude_code");
setMcpInClaudeCode(exists);
} catch {
// Claude CLI may not be installed
const list = await invoke<McpAgentInfo[]>("list_mcp_agents");
setAgents(list);
} catch (e) {
console.error("Failed to list MCP agents:", e);
}
}, []);
@@ -125,8 +174,7 @@ export function IntegrationsDialog({
void loadApiServerStatus();
void loadMcpConfig();
void loadMcpServerStatus();
void loadClaudeDesktopStatus();
void loadClaudeCodeStatus();
void loadAgents();
}
}, [
isOpen,
@@ -134,8 +182,7 @@ export function IntegrationsDialog({
loadApiServerStatus,
loadMcpConfig,
loadMcpServerStatus,
loadClaudeDesktopStatus,
loadClaudeCodeStatus,
loadAgents,
]);
const handleApiToggle = async (enabled: boolean) => {
@@ -181,6 +228,7 @@ export function IntegrationsDialog({
});
setSettings(next);
void loadMcpConfig();
void loadAgents();
showSuccessToast(t("integrations.mcpStarted", { port }));
} else {
await invoke("stop_mcp_server");
@@ -202,6 +250,53 @@ export function IntegrationsDialog({
}
};
const markAgentBusy = (id: string, busy: boolean) => {
setBusyAgentIds((prev) => {
const next = new Set(prev);
if (busy) next.add(id);
else next.delete(id);
return next;
});
};
const handleAddAgent = async (agent: McpAgentInfo) => {
markAgentBusy(agent.id, true);
try {
await invoke("add_mcp_to_agent", { agentId: agent.id });
showSuccessToast(
t("integrations.mcp.addedToClient", { name: agent.display_name }),
);
void loadAgents();
} catch (e) {
showErrorToast(translateBackendError(t, e), {
description: agent.display_name,
});
} finally {
markAgentBusy(agent.id, false);
}
};
const handleRemoveAgent = async (agent: McpAgentInfo) => {
markAgentBusy(agent.id, true);
try {
await invoke("remove_mcp_from_agent", { agentId: agent.id });
showSuccessToast(
t("integrations.mcp.removedFromClient", { name: agent.display_name }),
);
void loadAgents();
} catch (e) {
showErrorToast(translateBackendError(t, e), {
description: agent.display_name,
});
} finally {
markAgentBusy(agent.id, false);
}
};
const mcpUrl = mcpConfig
? `http://127.0.0.1:${mcpConfig.port}/mcp/${mcpConfig.token}`
: "";
return (
<Dialog
open={isOpen}
@@ -210,7 +305,7 @@ export function IntegrationsDialog({
}}
subPage={subPage}
>
<DialogContent className="max-w-xl max-h-[80vh] my-8 flex flex-col">
<DialogContent className="max-w-3xl max-h-[85vh] my-8 flex flex-col">
{!subPage && (
<DialogHeader className="shrink-0">
<DialogTitle>{t("integrations.title")}</DialogTitle>
@@ -218,200 +313,235 @@ export function IntegrationsDialog({
)}
<div className="overflow-y-auto flex-1 min-h-0">
<Tabs defaultValue="api" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="api">{t("integrations.tabApi")}</TabsTrigger>
<TabsTrigger value="mcp">{t("integrations.tabMcp")}</TabsTrigger>
</TabsList>
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
<AnimatedTabsList>
<AnimatedTabsTrigger value="api">
{t("integrations.tabApi")}
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="mcp">
{t("integrations.tabMcp")}
</AnimatedTabsTrigger>
</AnimatedTabsList>
<TabsContent value="api" className="space-y-4 mt-4">
<div className="flex items-center space-x-2">
<Checkbox
id="api-enabled"
checked={apiServerPort !== null}
disabled={isApiStarting}
onCheckedChange={(checked) => void handleApiToggle(!!checked)}
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor="api-enabled"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("integrations.apiEnableLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("integrations.apiEnableDescription")}
</p>
<AnimatedTabsContent
value="api"
className="mt-4 flex flex-col gap-4"
>
<div className="rounded-md border bg-card p-4 flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<LuPlug className="size-5 mt-0.5 text-muted-foreground" />
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium">
{t("integrations.apiEnableLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("integrations.apiEnableDescription")}
</p>
</div>
</div>
<AnimatedSwitch
checked={apiServerPort !== null}
disabled={isApiStarting}
onCheckedChange={(checked) => void handleApiToggle(checked)}
/>
</div>
{apiServerPort && (
<div className="flex items-center gap-2 text-xs">
<span className="size-1.5 rounded-full bg-success" />
<span className="text-muted-foreground">
{t("integrations.apiRunningOn")}
</span>
<code className="rounded bg-muted px-2 py-1 font-mono text-[11px]">
http://127.0.0.1:{apiServerPort}
</code>
</div>
)}
</div>
{settings.api_enabled && (
<div className="space-y-4 p-4 rounded-md border bg-muted/40">
<div className="space-y-2">
<Label className="text-sm font-medium">
{t("integrations.apiPortLabel")}
</Label>
<div className="flex items-center space-x-2">
<Button
size="sm"
disabled={
isApiStarting || apiServerPort === settings.api_port
}
onClick={async () => {
const port = settings.api_port;
if (port < 1 || port > 65535) {
showErrorToast(t("integrations.apiInvalidPort"), {
description: t(
"integrations.apiInvalidPortDescription",
),
});
return;
}
setIsApiStarting(true);
try {
await invoke("stop_api_server");
const next = await invoke<AppSettings>(
"save_app_settings",
{ settings },
);
setSettings(next);
const actualPort = await invoke<number>(
"start_api_server",
{ port },
);
setApiServerPort(actualPort);
if (actualPort !== port) {
showErrorToast(
t("integrations.apiPortInUse", { port }),
{
description: t(
"integrations.apiFallbackPort",
{ port: actualPort },
),
},
);
} else {
showSuccessToast(
t("integrations.apiRunning", {
port: actualPort,
}),
);
}
} catch (e) {
showErrorToast(t("integrations.apiStartFailed"), {
description:
e instanceof Error
? e.message
: t("integrations.apiUnknownError"),
});
} finally {
setIsApiStarting(false);
}
}}
>
{t("common.buttons.save")}
</Button>
<Input
type="number"
value={settings.api_port}
onChange={(e) => {
const val = Number.parseInt(e.target.value, 10);
if (!Number.isNaN(val)) {
setSettings({ ...settings, api_port: val });
}
}}
className="w-24 font-mono"
min={1}
max={65535}
/>
{apiServerPort && (
<span className="text-xs text-muted-foreground">
{t("common.status.running")}
</span>
)}
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">
{t("integrations.apiTokenLabel")}
</Label>
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<>
<div className="grid grid-cols-2 gap-4">
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.apiPortLabel")}
</Label>
<div className="flex items-center gap-2">
<Input
type={showApiToken ? "text" : "password"}
value={settings.api_token ?? ""}
readOnly
className="font-mono pr-10"
type="number"
value={settings.api_port}
onChange={(e) => {
const val = Number.parseInt(e.target.value, 10);
if (!Number.isNaN(val)) {
setSettings({ ...settings, api_port: val });
}
}}
className="w-24 font-mono"
min={1}
max={65535}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => {
setShowApiToken(!showApiToken);
variant="outline"
disabled={
isApiStarting || apiServerPort === settings.api_port
}
onClick={async () => {
const port = settings.api_port;
if (port < 1 || port > 65535) {
showErrorToast(t("integrations.apiInvalidPort"), {
description: t(
"integrations.apiInvalidPortDescription",
),
});
return;
}
setIsApiStarting(true);
try {
await invoke("stop_api_server");
const next = await invoke<AppSettings>(
"save_app_settings",
{ settings },
);
setSettings(next);
const actualPort = await invoke<number>(
"start_api_server",
{ port },
);
setApiServerPort(actualPort);
if (actualPort !== port) {
showErrorToast(
t("integrations.apiPortInUse", { port }),
{
description: t(
"integrations.apiFallbackPort",
{ port: actualPort },
),
},
);
} else {
showSuccessToast(
t("integrations.apiRunning", {
port: actualPort,
}),
);
}
} catch (e) {
showErrorToast(t("integrations.apiStartFailed"), {
description:
e instanceof Error
? e.message
: t("integrations.apiUnknownError"),
});
} finally {
setIsApiStarting(false);
}
}}
>
{showApiToken ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
{t("common.buttons.save")}
</Button>
</div>
</div>
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.apiTokenLabel")}
</Label>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
type={showApiToken ? "text" : "password"}
value={settings.api_token ?? ""}
readOnly
className="font-mono pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => {
setShowApiToken(!showApiToken);
}}
>
{showApiToken ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</Button>
</div>
<CopyToClipboard
text={settings.api_token ?? ""}
successMessage={t("integrations.tokenCopied")}
/>
</div>
</div>
</div>
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.apiExampleRequest")}
</Label>
<CopyToClipboard
text={settings.api_token ?? ""}
successMessage={t("integrations.tokenCopied")}
text={`curl -H "Authorization: Bearer ${settings.api_token ?? "${TOKEN}"}" \\\n http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
successMessage={t("common.buttons.copied")}
/>
</div>
<p className="text-xs text-muted-foreground">
{t("integrations.apiTokenHint", {
tokenSlot: "<token>",
})}
</p>
<pre className="font-mono text-[11px] whitespace-pre overflow-x-auto bg-background rounded p-3">
{`curl -H "Authorization: Bearer \${TOKEN}" \\
http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
</pre>
</div>
</div>
</>
)}
</TabsContent>
</AnimatedTabsContent>
<TabsContent value="mcp" className="space-y-4 mt-4">
<div className="flex items-center space-x-2">
<Checkbox
id="mcp-enabled"
checked={settings.mcp_enabled && mcpConfig !== null}
disabled={!termsAccepted || isMcpStarting}
onCheckedChange={(checked) => void handleMcpToggle(!!checked)}
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor="mcp-enabled"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("integrations.mcpEnableLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("integrations.mcpEnableDescription")}
{!termsAccepted && (
<span className="ml-1 text-warning">
{t("integrations.mcpAcceptTermsFirst")}
</span>
)}
</p>
<AnimatedTabsContent
value="mcp"
className="mt-4 flex flex-col gap-5"
>
<div className="rounded-md border bg-card p-4 flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<LuZap className="size-5 mt-0.5 text-muted-foreground" />
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium">
{t("integrations.mcpEnableLabel")}
</Label>
<p className="text-xs text-muted-foreground">
{t("integrations.mcpEnableDescription")}
{!termsAccepted && (
<span className="ml-1 text-warning">
{t("integrations.mcpAcceptTermsFirst")}
</span>
)}
</p>
</div>
</div>
<AnimatedSwitch
checked={settings.mcp_enabled && mcpConfig !== null}
disabled={!termsAccepted || isMcpStarting}
onCheckedChange={(checked) => void handleMcpToggle(checked)}
/>
</div>
</div>
{mcpConfig && (
<div className="space-y-4 p-4 rounded-md border bg-muted/40">
<div className="space-y-2">
<Label className="text-sm font-medium">
<>
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.mcp.url")}
</Label>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<div className="relative flex-1">
<Input
type={showMcpToken ? "text" : "password"}
value={`http://127.0.0.1:${mcpConfig.port}/mcp/${mcpConfig.token}`}
type={showMcpUrl ? "text" : "password"}
value={mcpUrl}
readOnly
className="font-mono text-xs pr-10"
/>
@@ -421,116 +551,88 @@ export function IntegrationsDialog({
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => {
setShowMcpToken(!showMcpToken);
setShowMcpUrl(!showMcpUrl);
}}
>
{showMcpToken ? (
<EyeOff className="h-4 w-4" />
{showMcpUrl ? (
<EyeOff className="size-4" />
) : (
<Eye className="h-4 w-4" />
<Eye className="size-4" />
)}
</Button>
</div>
<CopyToClipboard
text={`http://127.0.0.1:${mcpConfig.port}/mcp/${mcpConfig.token}`}
text={mcpUrl}
successMessage={t("integrations.mcp.urlCopied")}
/>
</div>
</div>
<div className="space-y-2 pt-1 border-t">
<p className="text-xs font-medium text-muted-foreground">
{t("integrations.mcp.claudeDesktopTitle")}
</p>
{mcpInClaudeDesktop ? (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={async () => {
try {
await invoke("remove_mcp_from_claude_desktop");
setMcpInClaudeDesktop(false);
showSuccessToast(
t("integrations.mcp.removedFromClaudeDesktop"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.removeFromClaudeDesktop")}
</Button>
) : (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={async () => {
try {
await invoke("add_mcp_to_claude_desktop");
setMcpInClaudeDesktop(true);
showSuccessToast(
t("integrations.mcp.addedToClaudeDesktop"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.addToClaudeDesktop")}
</Button>
)}
<div className="flex flex-col gap-3">
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("integrations.mcp.clientsLabel")}
</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{agents.map((agent) => {
const busy = busyAgentIds.has(agent.id);
return (
<div
key={agent.id}
className="rounded-md border bg-card px-3 py-2.5 flex items-center gap-3"
>
<div className="grid place-items-center size-8 rounded-md bg-muted shrink-0">
<AgentIcon category={agent.category} />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">
{agent.display_name}
</p>
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
{categoryLabel(t, agent.category)}
</p>
</div>
{agent.connected ? (
<div className="flex items-center gap-1">
<span className="inline-flex items-center gap-1 rounded-md border bg-muted px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-foreground">
<LuCheck className="size-3" />
{t("integrations.mcp.connected")}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-destructive"
disabled={busy}
onClick={() => void handleRemoveAgent(agent)}
aria-label={t(
"integrations.mcp.removeAriaLabel",
{
name: agent.display_name,
},
)}
>
<LuTrash2 className="size-4" />
</Button>
</div>
) : (
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => void handleAddAgent(agent)}
>
{t("integrations.mcp.add")}
</Button>
)}
</div>
);
})}
</div>
</div>
<div className="space-y-2 pt-1 border-t">
<p className="text-xs font-medium text-muted-foreground">
{t("integrations.mcp.claudeCodeTitle")}
</p>
{mcpInClaudeCode ? (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={async () => {
try {
await invoke("remove_mcp_from_claude_code");
setMcpInClaudeCode(false);
showSuccessToast(
t("integrations.mcp.removedFromClaudeCode"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.removeFromClaudeCode")}
</Button>
) : (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={async () => {
try {
await invoke("add_mcp_to_claude_code");
setMcpInClaudeCode(true);
showSuccessToast(
t("integrations.mcp.addedToClaudeCode"),
);
} catch (e) {
showErrorToast(String(e));
}
}}
>
{t("integrations.mcp.addToClaudeCode")}
</Button>
)}
</div>
</div>
</>
)}
</TabsContent>
</Tabs>
</AnimatedTabsContent>
</AnimatedTabs>
</div>
</DialogContent>
</Dialog>
+1 -1
View File
@@ -17,7 +17,7 @@ export const LoadingButton = ({ isLoading, className, ...props }: Props) => {
disabled={props.disabled || isLoading}
>
{isLoading ? (
<LuLoaderCircle className="h-4 w-4 animate-spin" />
<LuLoaderCircle className="size-4 animate-spin" />
) : (
props.children
)}
+4 -4
View File
@@ -26,6 +26,10 @@ interface LocationProxyDialogProps {
onClose: () => void;
}
function LoadingSpinner() {
return <Loader2 className="size-4 animate-spin text-muted-foreground" />;
}
export function LocationProxyDialog({
isOpen,
onClose,
@@ -219,10 +223,6 @@ export function LocationProxyDialog({
const cityOptions = cities.map((c) => ({ value: c.code, label: c.name }));
const ispOptions = isps.map((i) => ({ value: i.code, label: i.name }));
const LoadingSpinner = () => (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
+1 -1
View File
@@ -434,7 +434,7 @@ const MultipleSelector = React.forwardRef<
handleUnselect(option);
}}
>
<LuX className="w-3 h-3 text-muted-foreground hover:text-foreground" />
<LuX className="size-3 text-muted-foreground hover:text-foreground" />
</button>
</Badge>
);
+3 -3
View File
@@ -131,9 +131,9 @@ export function PermissionDialog({
const getPermissionIcon = (type: PermissionType) => {
switch (type) {
case "microphone":
return <BsMic className="w-8 h-8" />;
return <BsMic className="size-8" />;
case "camera":
return <BsCamera className="w-8 h-8" />;
return <BsCamera className="size-8" />;
}
};
@@ -195,7 +195,7 @@ export function PermissionDialog({
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader className="text-center">
<div className="flex justify-center items-center mx-auto mb-4 w-16 h-16 bg-primary/15 rounded-full">
<div className="flex justify-center items-center mx-auto mb-4 size-16 bg-primary/15 rounded-full">
{getPermissionIcon(permissionType)}
</div>
<DialogTitle className="text-xl">
+80 -45
View File
@@ -350,11 +350,11 @@ function ExtCell({
disabled={isSaving}
className="flex items-center gap-1.5 h-7 px-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded transition-colors duration-100 w-full text-left disabled:opacity-50"
>
<LuPuzzle className="w-3 h-3 shrink-0" />
<LuPuzzle className="size-3 shrink-0" />
<span className="truncate flex-1" title={label}>
{label}
</span>
<LuChevronDown className="w-3 h-3 shrink-0 text-muted-foreground" />
<LuChevronDown className="size-3 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-0" align="start">
@@ -369,7 +369,7 @@ function ExtCell({
void onPick(null);
}}
>
{groupId === null && <LuCheck className="mr-2 w-3.5 h-3.5" />}
{groupId === null && <LuCheck className="mr-2 size-3.5" />}
<span className={groupId === null ? "" : "ml-5"}>
{meta.t("profiles.table.extDefault")}
</span>
@@ -382,7 +382,7 @@ function ExtCell({
void onPick(g.id);
}}
>
{groupId === g.id && <LuCheck className="mr-2 w-3.5 h-3.5" />}
{groupId === g.id && <LuCheck className="mr-2 size-3.5" />}
<span className={groupId === g.id ? "" : "ml-5"}>
{g.name}
</span>
@@ -416,6 +416,10 @@ function DnsCell({
{ value: "pro_plus", labelKey: "dnsBlocklist.proPlus" },
{ value: "ultimate", labelKey: "dnsBlocklist.ultimate" },
];
const currentLabel =
level === null
? null
: (LEVELS.find((l) => l.value === level)?.labelKey ?? null);
const onPick = async (nextLevel: string | null) => {
setIsSaving(true);
@@ -445,11 +449,11 @@ function DnsCell({
: meta.t("dnsBlocklist.none")
}
>
<FiWifi className="w-3 h-3 shrink-0" />
<span className="flex-1 truncate uppercase text-[10px] font-mono tracking-wide">
{level ?? "—"}
<FiWifi className="size-3 shrink-0" />
<span className="flex-1 truncate text-[11px] tracking-wide">
{currentLabel ? meta.t(currentLabel) : "—"}
</span>
<LuChevronDown className="w-3 h-3 shrink-0 text-muted-foreground" />
<LuChevronDown className="size-3 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-0" align="start">
@@ -462,7 +466,7 @@ function DnsCell({
void onPick(null);
}}
>
{level === null && <LuCheck className="mr-2 w-3.5 h-3.5" />}
{level === null && <LuCheck className="mr-2 size-3.5" />}
<span className={level === null ? "" : "ml-5"}>
{meta.t("dnsBlocklist.none")}
</span>
@@ -475,9 +479,7 @@ function DnsCell({
void onPick(l.value);
}}
>
{level === l.value && (
<LuCheck className="mr-2 w-3.5 h-3.5" />
)}
{level === l.value && <LuCheck className="mr-2 size-3.5" />}
<span className={level === l.value ? "" : "ml-5"}>
{meta.t(l.labelKey)}
</span>
@@ -1050,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({
@@ -1082,6 +1091,8 @@ export function ProfilesDataTable({
onSetPassword,
onChangePassword,
onRemovePassword,
infoDialogProfile,
onInfoDialogProfileChange,
}: ProfilesDataTableProps) {
const { t } = useTranslation();
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
@@ -1153,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] =
@@ -1960,7 +1985,7 @@ export function ProfilesDataTable({
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex justify-center items-center w-4 h-4">
<span className="flex justify-center items-center size-4">
<button
type="button"
className="flex justify-center items-center p-0 border-none cursor-pointer"
@@ -1969,9 +1994,9 @@ export function ProfilesDataTable({
}}
aria-label={t("common.aria.selectProfile")}
>
<span className="w-4 h-4 group">
<OsIcon className="w-4 h-4 text-muted-foreground group-hover:hidden" />
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-150" />
<span className="size-4 group">
<OsIcon className="size-4 text-muted-foreground group-hover:hidden" />
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none size-4 hidden group-hover:block pointer-events-none items-center justify-center duration-150" />
</span>
</button>
</span>
@@ -1999,14 +2024,14 @@ export function ProfilesDataTable({
sideOffset={4}
horizontalOffset={8}
>
<span className="flex justify-center items-center w-4 h-4">
<span className="flex justify-center items-center size-4">
<Checkbox
checked={isSelected}
onCheckedChange={(value) => {
meta.handleCheckboxChange(profile.id, !!value);
}}
aria-label={t("common.aria.selectRow")}
className="w-4 h-4"
className="size-4"
/>
</span>
</NonHoverableTooltip>
@@ -2025,9 +2050,9 @@ export function ProfilesDataTable({
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex justify-center items-center w-4 h-4 cursor-not-allowed">
<span className="flex justify-center items-center size-4 cursor-not-allowed">
{IconComponent && (
<IconComponent className="w-4 h-4 opacity-50" />
<IconComponent className="size-4 opacity-50" />
)}
</span>
</TooltipTrigger>
@@ -2047,14 +2072,14 @@ export function ProfilesDataTable({
sideOffset={4}
horizontalOffset={8}
>
<span className="flex justify-center items-center w-4 h-4">
<span className="flex justify-center items-center size-4">
<Checkbox
checked={isSelected}
onCheckedChange={(value) => {
meta.handleCheckboxChange(profile.id, !!value);
}}
aria-label={t("common.aria.selectRow")}
className="w-4 h-4"
className="size-4"
/>
</span>
</NonHoverableTooltip>
@@ -2067,7 +2092,7 @@ export function ProfilesDataTable({
sideOffset={4}
horizontalOffset={8}
>
<span className="flex relative justify-center items-center w-4 h-4">
<span className="flex relative justify-center items-center size-4">
<button
type="button"
className="flex justify-center items-center p-0 border-none cursor-pointer"
@@ -2076,11 +2101,11 @@ export function ProfilesDataTable({
}}
aria-label={t("common.aria.selectProfile")}
>
<span className="w-4 h-4 group">
<span className="size-4 group">
{IconComponent && (
<IconComponent className="w-4 h-4 group-hover:hidden" />
<IconComponent className="size-4 group-hover:hidden" />
)}
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-150" />
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none size-4 hidden group-hover:block pointer-events-none items-center justify-center duration-150" />
</span>
</button>
</span>
@@ -2194,7 +2219,7 @@ export function ProfilesDataTable({
<Tooltip>
<TooltipTrigger asChild>
<span>
<LuTriangleAlert className="w-4 h-4 text-warning" />
<LuTriangleAlert className="size-4 text-warning" />
</span>
</TooltipTrigger>
<TooltipContent>
@@ -2217,7 +2242,7 @@ export function ProfilesDataTable({
: meta.t("profiles.actions.launch")
}
className={cn(
"h-7 w-7 p-0 grid place-items-center",
"size-7 p-0 grid place-items-center",
!canLaunch && "opacity-50 cursor-not-allowed",
canLaunch && "cursor-pointer",
isFollower && "border-accent",
@@ -2231,11 +2256,11 @@ export function ProfilesDataTable({
}
>
{isLaunching || isStopping ? (
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
<div className="size-3 rounded-full border border-current animate-spin border-t-transparent" />
) : isRunning ? (
<LuSquare className="w-3.5 h-3.5 fill-current" />
<LuSquare className="size-3.5 fill-current" />
) : (
<LuPlay className="w-3.5 h-3.5 fill-current" />
<LuPlay className="size-3.5 fill-current" />
)}
</RippleButton>
</span>
@@ -2265,9 +2290,9 @@ export function ProfilesDataTable({
>
{meta.t("common.labels.name")}
{column.getIsSorted() === "asc" ? (
<LuChevronUp className="ml-2 w-4 h-4" />
<LuChevronUp className="ml-2 size-4" />
) : column.getIsSorted() === "desc" ? (
<LuChevronDown className="ml-2 w-4 h-4" />
<LuChevronDown className="ml-2 size-4" />
) : null}
</Button>
);
@@ -2382,7 +2407,7 @@ export function ProfilesDataTable({
<Tooltip>
<TooltipTrigger asChild>
<span>
<LuLock className="w-3 h-3 text-muted-foreground" />
<LuLock className="size-3 text-muted-foreground" />
</span>
</TooltipTrigger>
<TooltipContent>
@@ -2606,7 +2631,7 @@ export function ProfilesDataTable({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedId === null
? "opacity-100"
: "opacity-0",
@@ -2633,7 +2658,7 @@ export function ProfilesDataTable({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
effectiveProxyId === proxy.id &&
!effectiveVpn
? "opacity-100"
@@ -2659,7 +2684,7 @@ export function ProfilesDataTable({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
effectiveVpnId === vpn.id
? "opacity-100"
: "opacity-0",
@@ -2701,7 +2726,7 @@ export function ProfilesDataTable({
)
}
>
<span className="mr-2 h-4 w-4" />+{" "}
<span className="mr-2 size-4" />+{" "}
{country.name}
</CommandItem>
))}
@@ -2793,11 +2818,11 @@ export function ProfilesDataTable({
<span className="flex justify-center items-center h-9 w-full">
{dot.encrypted ? (
<LuLock
className={`w-3 h-3 ${dot.color.replace("bg-", "text-")}${dot.animate ? " animate-pulse" : ""}`}
className={`size-3 ${dot.color.replace("bg-", "text-")}${dot.animate ? " animate-pulse" : ""}`}
/>
) : (
<span
className={`w-2 h-2 rounded-full ${dot.color}${dot.animate ? " animate-pulse" : ""}`}
className={`size-2 rounded-full ${dot.color}${dot.animate ? " animate-pulse" : ""}`}
/>
)}
</span>
@@ -2818,7 +2843,7 @@ export function ProfilesDataTable({
<div className="flex justify-end items-center h-9 w-full">
<Button
variant="ghost"
className="p-0 w-7 h-7"
className="p-0 size-7"
disabled={!meta.isClient}
onClick={() => {
setProfileForInfoDialog(profile);
@@ -2827,14 +2852,14 @@ export function ProfilesDataTable({
<span className="sr-only">
{t("profiles.aria.profileInfo")}
</span>
<LuInfo className="w-4 h-4" />
<LuInfo className="size-4" />
</Button>
</div>
);
},
},
],
[t],
[t, setProfileForInfoDialog],
);
const table = useReactTable({
@@ -2889,6 +2914,14 @@ export function ProfilesDataTable({
<div
ref={scrollParentRef}
className="overflow-auto relative flex-1 min-h-0 scroll-fade"
style={
{
// Sticky table header is 32px tall (h-8); shift the top
// fade band below it so the header stays fully opaque and
// only body rows fade as they scroll past.
"--scroll-fade-top-offset": "32px",
} as React.CSSProperties
}
>
<Table className="table-fixed">
<TableHeader className="overflow-visible sticky top-0 z-10 bg-background [&_tr]:border-0">
@@ -3005,7 +3038,9 @@ export function ProfilesDataTable({
/>
{profileForInfoDialog &&
(() => {
const infoProfile = profileForInfoDialog;
const infoProfile =
profiles.find((p) => p.id === profileForInfoDialog.id) ??
profileForInfoDialog;
const infoIsRunning =
browserState.isClient && runningProfiles.has(infoProfile.id);
const infoIsLaunching = launchingProfiles.has(infoProfile.id);
+153 -127
View File
@@ -16,7 +16,6 @@ import {
LuGroup,
LuKey,
LuLink,
LuLock,
LuLockOpen,
LuPlus,
LuPuzzle,
@@ -33,6 +32,7 @@ import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -48,13 +48,9 @@ import {
} from "@/components/ui/select";
import { WayfernConfigForm } from "@/components/wayfern-config-form";
import { translateBackendError } from "@/lib/backend-errors";
import {
getBrowserDisplayName,
getOSDisplayName,
getProfileIcon,
isCrossOsProfile,
} from "@/lib/browser-utils";
import { getProfileIcon } from "@/lib/browser-utils";
import { formatRelativeTime } from "@/lib/flag-utils";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type {
BrowserProfile,
@@ -94,14 +90,14 @@ interface ProfileInfoDialogProps {
syncStatuses: Record<string, { status: string; error?: string }>;
}
function OSIcon({ os }: { os: string }) {
function _OSIcon({ os }: { os: string }) {
switch (os) {
case "macos":
return <FaApple className="w-3.5 h-3.5" />;
return <FaApple className="size-3.5" />;
case "windows":
return <FaWindows className="w-3.5 h-3.5" />;
return <FaWindows className="size-3.5" />;
case "linux":
return <FaLinux className="w-3.5 h-3.5" />;
return <FaLinux className="size-3.5" />;
default:
return null;
}
@@ -290,12 +286,8 @@ export function ProfileInfoDialog({
action();
};
const releaseLabel =
profile.release_type.charAt(0).toUpperCase() +
profile.release_type.slice(1);
const hasTags = profile.tags && profile.tags.length > 0;
const hasNote = !!profile.note;
const showCrossOs = isCrossOsProfile(profile);
// Items in the settings tab `actions` list MUST only open another dialog
// (or trigger a navigation/action that closes this one). Do NOT put inline
@@ -317,7 +309,7 @@ export function ProfileInfoDialog({
const actions: ActionItem[] = [
{
icon: <LuGlobe className="w-4 h-4" />,
icon: <LuGlobe className="size-4" />,
label: t("profiles.actions.viewNetwork"),
onClick: () => {
handleAction(() => onOpenTrafficDialog?.(profile.id));
@@ -325,7 +317,7 @@ export function ProfileInfoDialog({
disabled: isCrossOs,
},
{
icon: <LuRefreshCw className="w-4 h-4" />,
icon: <LuRefreshCw className="size-4" />,
label: t("profiles.actions.syncSettings"),
onClick: () => {
handleAction(() => onOpenProfileSyncDialog?.(profile));
@@ -334,7 +326,7 @@ export function ProfileInfoDialog({
hidden: profile.ephemeral === true,
},
{
icon: <LuGroup className="w-4 h-4" />,
icon: <LuGroup className="size-4" />,
label: t("profiles.actions.assignToGroup"),
onClick: () => {
handleAction(() => onAssignProfilesToGroup?.([profile.id]));
@@ -343,7 +335,7 @@ export function ProfileInfoDialog({
runningBadge: isRunning,
},
{
icon: <LuFingerprint className="w-4 h-4" />,
icon: <LuFingerprint className="size-4" />,
label: t("profiles.actions.changeFingerprint"),
onClick: () => {
handleAction(() => onConfigureCamoufox?.(profile));
@@ -353,7 +345,7 @@ export function ProfileInfoDialog({
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
},
{
icon: <LuUsers className="w-4 h-4" />,
icon: <LuUsers className="size-4" />,
label: t("profiles.synchronizer.launchWithSync"),
onClick: () => {
handleAction(() => onLaunchWithSync?.(profile));
@@ -363,7 +355,7 @@ export function ProfileInfoDialog({
hidden: profile.browser !== "wayfern" || !onLaunchWithSync,
},
{
icon: <LuCopy className="w-4 h-4" />,
icon: <LuCopy className="size-4" />,
label: t("profiles.actions.copyCookiesToProfile"),
onClick: () => {
handleAction(() => onCopyCookiesToProfile?.(profile));
@@ -376,7 +368,7 @@ export function ProfileInfoDialog({
!onCopyCookiesToProfile,
},
{
icon: <LuCookie className="w-4 h-4" />,
icon: <LuCookie className="size-4" />,
label: t("profileInfo.actions.manageCookies"),
onClick: () => {
handleAction(() => onOpenCookieManagement?.(profile));
@@ -389,7 +381,7 @@ export function ProfileInfoDialog({
!onOpenCookieManagement,
},
{
icon: <LuSettings className="w-4 h-4" />,
icon: <LuSettings className="size-4" />,
label: t("profiles.actions.clone"),
onClick: () => {
handleAction(() => onCloneProfile?.(profile));
@@ -399,7 +391,7 @@ export function ProfileInfoDialog({
hidden: profile.ephemeral === true,
},
{
icon: <LuPuzzle className="w-4 h-4" />,
icon: <LuPuzzle className="size-4" />,
label: t("profileInfo.actions.assignExtensionGroup"),
onClick: () => {
handleAction(() => onAssignExtensionGroup?.([profile.id]));
@@ -409,21 +401,21 @@ export function ProfileInfoDialog({
hidden: profile.ephemeral === true,
},
{
icon: <LuShieldCheck className="w-4 h-4" />,
icon: <LuShieldCheck className="size-4" />,
label: t("profileInfo.network.bypassRulesTitle"),
onClick: () => {
handleAction(() => onOpenBypassRules?.(profile));
},
},
{
icon: <LuShield className="w-4 h-4" />,
icon: <LuShield className="size-4" />,
label: t("dnsBlocklist.title"),
onClick: () => {
handleAction(() => onOpenDnsBlocklist?.(profile));
},
},
{
icon: <LuLink className="w-4 h-4" />,
icon: <LuLink className="size-4" />,
label: t("profiles.actions.launchHook"),
onClick: () => {
handleAction(() => onOpenLaunchHook?.(profile));
@@ -431,7 +423,7 @@ export function ProfileInfoDialog({
hidden: !onOpenLaunchHook,
},
{
icon: <LuKey className="w-4 h-4" />,
icon: <LuKey className="size-4" />,
label: t("profiles.actions.setPassword"),
onClick: () => {
handleAction(() => onSetPassword?.(profile));
@@ -444,7 +436,7 @@ export function ProfileInfoDialog({
!onSetPassword,
},
{
icon: <LuKey className="w-4 h-4" />,
icon: <LuKey className="size-4" />,
label: t("profiles.actions.changePassword"),
onClick: () => {
handleAction(() => onChangePassword?.(profile));
@@ -454,7 +446,7 @@ export function ProfileInfoDialog({
hidden: profile.password_protected !== true || !onChangePassword,
},
{
icon: <LuLockOpen className="w-4 h-4" />,
icon: <LuLockOpen className="size-4" />,
label: t("profiles.actions.removePassword"),
onClick: () => {
handleAction(() => onRemovePassword?.(profile));
@@ -465,7 +457,7 @@ export function ProfileInfoDialog({
destructive: true,
},
{
icon: <LuTrash2 className="w-4 h-4" />,
icon: <LuTrash2 className="size-4" />,
label: t("profiles.actions.delete"),
onClick: () => {
handleAction(() => onDeleteProfile?.(profile));
@@ -491,10 +483,8 @@ export function ProfileInfoDialog({
<ProfileInfoLayout
profile={profile}
ProfileIcon={ProfileIcon}
releaseLabel={releaseLabel}
isRunning={isRunning}
isDisabled={isDisabled}
showCrossOs={showCrossOs}
networkLabel={networkLabel}
groupName={groupName}
extensionGroupName={extensionGroupName}
@@ -520,10 +510,8 @@ export function ProfileInfoDialog({
interface ProfileInfoLayoutProps {
profile: BrowserProfile;
ProfileIcon: React.ComponentType<{ className?: string }>;
releaseLabel: string;
isRunning: boolean;
isDisabled: boolean;
showCrossOs: boolean;
networkLabel: string;
groupName: string | null;
extensionGroupName: string | null;
@@ -564,10 +552,8 @@ type ProfileSection =
function ProfileInfoLayout({
profile,
ProfileIcon,
releaseLabel,
isRunning,
isDisabled,
showCrossOs,
networkLabel,
groupName,
extensionGroupName,
@@ -646,12 +632,12 @@ function ProfileInfoLayout({
}[] = [
{
id: "overview",
icon: <LuClipboard className="w-3.5 h-3.5" />,
icon: <LuClipboard className="size-3.5" />,
label: t("profileInfo.sections.overview"),
},
{
id: "fingerprint",
icon: <LuFingerprint className="w-3.5 h-3.5" />,
icon: <LuFingerprint className="size-3.5" />,
label: t("profileInfo.sections.fingerprint"),
badge: profile.password_protected
? t("profileInfo.badges.locked")
@@ -660,13 +646,13 @@ function ProfileInfoLayout({
},
{
id: "network",
icon: <LuGlobe className="w-3.5 h-3.5" />,
icon: <LuGlobe className="size-3.5" />,
label: t("profileInfo.sections.network"),
badge: profile.proxy_id || profile.vpn_id ? networkLabel : undefined,
},
{
id: "cookies",
icon: <LuCookie className="w-3.5 h-3.5" />,
icon: <LuCookie className="size-3.5" />,
label: t("profileInfo.sections.cookies"),
badge:
cookieCount !== null && cookieCount > 0
@@ -676,26 +662,26 @@ function ProfileInfoLayout({
},
{
id: "extensions",
icon: <LuPuzzle className="w-3.5 h-3.5" />,
icon: <LuPuzzle className="size-3.5" />,
label: t("profileInfo.sections.extensions"),
badge: extensionGroupName ?? undefined,
hidden: !extensionAction,
},
{
id: "sync",
icon: <LuRefreshCw className="w-3.5 h-3.5" />,
icon: <LuRefreshCw className="size-3.5" />,
label: t("profileInfo.sections.sync"),
hidden: !syncAction,
},
{
id: "automation",
icon: <LuLink className="w-3.5 h-3.5" />,
icon: <LuLink className="size-3.5" />,
label: t("profileInfo.sections.launchHook"),
badge: profile.launch_hook ? t("profileInfo.badges.active") : undefined,
},
{
id: "security",
icon: <LuKey className="w-3.5 h-3.5" />,
icon: <LuKey className="size-3.5" />,
label: t("profileInfo.sections.security"),
},
];
@@ -704,7 +690,7 @@ function ProfileInfoLayout({
<>
{/* Top bar */}
<div className="flex items-center gap-2 h-11 px-3 border-b border-border shrink-0">
<LuUsers className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<LuUsers className="size-3.5 text-muted-foreground shrink-0" />
<div className="flex items-center gap-1.5 text-xs min-w-0 flex-1">
<span className="font-semibold">
{t("profileInfo.breadcrumbRoot")}
@@ -720,7 +706,7 @@ function ProfileInfoLayout({
disabled={isDisabled}
onClick={() => onCloneProfile(profile)}
>
<LuCopy className="w-3 h-3" />
<LuCopy className="size-3" />
{t("profileInfo.duplicate")}
</Button>
)}
@@ -728,9 +714,9 @@ function ProfileInfoLayout({
type="button"
aria-label={t("common.buttons.close")}
onClick={onClose}
className="grid place-items-center w-7 h-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors duration-100"
className="grid place-items-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors duration-100"
>
<LuX className="w-3.5 h-3.5" />
<LuX className="size-3.5" />
</button>
</div>
@@ -773,7 +759,7 @@ function ProfileInfoLayout({
disabled={deleteAction.disabled}
className="flex items-center gap-2 h-7 px-2 rounded-md text-xs transition-colors duration-100 text-destructive hover:bg-destructive/10 disabled:opacity-50 disabled:pointer-events-none"
>
<LuTrash2 className="w-3.5 h-3.5 shrink-0" />
<LuTrash2 className="size-3.5 shrink-0" />
<span className="flex-1 text-left">
{t("profileInfo.sections.delete")}
</span>
@@ -789,7 +775,7 @@ function ProfileInfoLayout({
{/* Hero */}
<div className="flex items-center gap-3">
<div className="rounded-lg bg-muted p-2.5 shrink-0">
<ProfileIcon className="w-7 h-7 text-foreground" />
<ProfileIcon className="size-7 text-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
@@ -798,63 +784,8 @@ function ProfileInfoLayout({
</h3>
</div>
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px]">
<span className="font-mono uppercase text-muted-foreground">
{getBrowserDisplayName(profile.browser)}
</span>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">
{groupName ?? t("profileInfo.values.none")}
</span>
{isRunning && (
<>
<span className="text-muted-foreground">·</span>
<span className="inline-flex items-center gap-1 text-success">
<span className="w-1.5 h-1.5 rounded-full bg-success" />
{t("common.status.running")}
</span>
</>
)}
{profile.ephemeral && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground uppercase">
{t("profiles.ephemeralBadge")}
</span>
</>
)}
{profile.password_protected && (
<>
<span className="text-muted-foreground">·</span>
<span className="inline-flex items-center gap-1 text-muted-foreground">
<LuLock className="w-3 h-3" />
{t("profiles.passwordProtectedBadge")}
</span>
</>
)}
{showCrossOs && (
<>
<span className="text-muted-foreground">·</span>
<span className="inline-flex items-center gap-1 text-muted-foreground">
<OSIcon
os={
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os ||
""
}
/>
{getOSDisplayName(
profile.host_os ||
profile.camoufox_config?.os ||
profile.wayfern_config?.os ||
"",
)}
</span>
</>
)}
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">
{releaseLabel}
<span className="font-mono text-muted-foreground">
{profile.version}
</span>
</div>
</div>
@@ -875,9 +806,9 @@ function ProfileInfoLayout({
aria-label={t("common.buttons.copy")}
>
{copied ? (
<LuClipboardCheck className="w-3.5 h-3.5" />
<LuClipboardCheck className="size-3.5" />
) : (
<LuClipboard className="w-3.5 h-3.5" />
<LuClipboard className="size-3.5" />
)}
</button>
</div>
@@ -1082,7 +1013,7 @@ function _SectionAction({
>
{icon}
<span className="flex-1">{label}</span>
<LuChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
<LuChevronRight className="size-3.5 text-muted-foreground" />
</button>
);
}
@@ -1132,7 +1063,7 @@ function LaunchHookEditor({
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<LuLink className="w-4 h-4" />
<LuLink className="size-4" />
{t("profileInfo.sections.launchHook")}
</div>
<p className="text-xs text-muted-foreground">
@@ -1216,7 +1147,7 @@ function SyncSectionInline({
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<LuRefreshCw className="w-4 h-4" />
<LuRefreshCw className="size-4" />
{t("profileInfo.sections.sync")}
</div>
<p className="text-xs text-muted-foreground">
@@ -1331,7 +1262,7 @@ function NetworkSectionInline({
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<LuGlobe className="w-4 h-4" />
<LuGlobe className="size-4" />
{t("profileInfo.sections.network")}
</div>
<p className="text-xs text-muted-foreground">
@@ -1464,7 +1395,7 @@ function ExtensionsSectionInline({
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<LuPuzzle className="w-4 h-4" />
<LuPuzzle className="size-4" />
{t("profileInfo.sections.extensions")}
</div>
<p className="text-xs text-muted-foreground">
@@ -1553,7 +1484,7 @@ function CookiesSectionInline({
return (
<div className="flex flex-col gap-3 min-h-0 flex-1">
<div className="flex items-center gap-2 text-sm font-semibold">
<LuCookie className="w-4 h-4" />
<LuCookie className="size-4" />
{t("profileInfo.sections.cookies")}
</div>
<p className="text-xs text-muted-foreground">
@@ -1651,7 +1582,7 @@ function FingerprintSectionInline({
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<LuFingerprint className="w-4 h-4" />
<LuFingerprint className="size-4" />
{t("profileInfo.sections.fingerprint")}
</div>
<p className="text-xs text-muted-foreground">
@@ -1705,7 +1636,7 @@ function FingerprintSectionInline({
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<LuFingerprint className="w-4 h-4" />
<LuFingerprint className="size-4" />
{t("profileInfo.sections.fingerprint")}
</div>
<p className="text-xs text-muted-foreground">
@@ -1716,7 +1647,7 @@ function FingerprintSectionInline({
<SharedCamoufoxConfigForm
config={camoufoxConfig}
onConfigChange={onCamoufoxChange}
forceAdvanced={false}
forceAdvanced={true}
readOnly={isDisabled}
browserType="camoufox"
crossOsUnlocked={crossOsUnlocked}
@@ -1729,6 +1660,7 @@ function FingerprintSectionInline({
<WayfernConfigForm
config={wayfernConfig}
onConfigChange={onWayfernChange}
forceAdvanced={true}
readOnly={isDisabled}
crossOsUnlocked={crossOsUnlocked}
profileVersion={profile.version}
@@ -1739,7 +1671,7 @@ function FingerprintSectionInline({
{error && <p className="text-xs text-destructive">{error}</p>}
{success && !error && <p className="text-xs text-success">{success}</p>}
<div className="flex items-center gap-2 sticky bottom-0 bg-background pt-2 -mx-3 px-3 -mb-3 pb-3 border-t border-border">
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
<Button
size="sm"
className="h-7 text-xs"
@@ -1790,6 +1722,30 @@ function SecuritySectionInline({
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [success, setSuccess] = React.useState<string | null>(null);
const [isVerifyOpen, setIsVerifyOpen] = React.useState(false);
const [verifyPassword, setVerifyPassword] = React.useState("");
const [isVerifying, setIsVerifying] = React.useState(false);
const onVerify = async () => {
setIsVerifying(true);
try {
await invoke("verify_profile_password", {
profileId: profile.id,
password: verifyPassword,
});
showSuccessToast(t("profilePassword.verifyDialog.matchToast"));
setIsVerifyOpen(false);
setVerifyPassword("");
} catch (e) {
const message = translateBackendError(
t as unknown as Parameters<typeof translateBackendError>[0],
e,
);
showErrorToast(message);
} finally {
setIsVerifying(false);
}
};
// Reset the form whenever the underlying profile state changes (e.g. the
// user just set a password — flip to "change" mode and clear fields).
@@ -1837,24 +1793,29 @@ function SecuritySectionInline({
profileId: profile.id,
password,
});
setSuccess(t("profilePassword.toasts.set"));
showSuccessToast(t("profilePassword.toasts.set"));
} else if (mode === "change") {
await invoke("change_profile_password", {
profileId: profile.id,
oldPassword,
newPassword: password,
});
setSuccess(t("profilePassword.toasts.changed"));
showSuccessToast(t("profilePassword.toasts.changed"));
} else {
await invoke("remove_profile_password", {
profileId: profile.id,
password: oldPassword,
});
setSuccess(t("profilePassword.toasts.removed"));
showSuccessToast(t("profilePassword.toasts.removed"));
}
reset();
} catch (e) {
setError(String(e));
const message = translateBackendError(
t as unknown as Parameters<typeof translateBackendError>[0],
e,
);
setError(message);
showErrorToast(message);
} finally {
setIsSubmitting(false);
}
@@ -1863,7 +1824,7 @@ function SecuritySectionInline({
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<LuKey className="w-4 h-4" />
<LuKey className="size-4" />
{t("profileInfo.sections.security")}
</div>
<p className="text-xs text-muted-foreground">
@@ -1874,6 +1835,19 @@ function SecuritySectionInline({
{profile.password_protected && (
<div className="flex gap-1.5">
<button
type="button"
onClick={() => {
setVerifyPassword("");
setIsVerifyOpen(true);
}}
className={cn(
"flex-1 h-7 px-2 text-xs rounded-md border transition-colors",
"border-border text-muted-foreground hover:text-foreground hover:bg-accent/50",
)}
>
{t("profilePassword.modes.validate")}
</button>
<button
type="button"
onClick={() => {
@@ -1973,6 +1947,58 @@ function SecuritySectionInline({
? t("profilePassword.modes.change")
: t("profilePassword.modes.remove")}
</Button>
<Dialog
open={isVerifyOpen}
onOpenChange={(open) => {
if (!isVerifying) {
setIsVerifyOpen(open);
if (!open) setVerifyPassword("");
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("profilePassword.verifyDialog.title")}</DialogTitle>
<DialogDescription>
{t("profilePassword.verifyDialog.description")}
</DialogDescription>
</DialogHeader>
<Input
type="password"
placeholder={t("profilePassword.fields.currentPassword")}
value={verifyPassword}
autoFocus
onChange={(e) => setVerifyPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && verifyPassword.length > 0) {
e.preventDefault();
void onVerify();
}
}}
/>
<DialogFooter>
<Button
variant="outline"
disabled={isVerifying}
onClick={() => {
setIsVerifyOpen(false);
setVerifyPassword("");
}}
>
{t("common.buttons.cancel")}
</Button>
<Button
disabled={isVerifying || verifyPassword.length === 0}
onClick={() => void onVerify()}
>
{isVerifying
? t("common.buttons.loading")
: t("profilePassword.verifyDialog.submit")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -2226,7 +2252,7 @@ export function ProfileBypassRulesDialog({
onClick={handleAddRule}
disabled={!newRule.trim()}
>
<LuPlus className="w-4 h-4 mr-1" />
<LuPlus className="size-4 mr-1" />
{t("profileInfo.network.addRule")}
</Button>
</div>
@@ -2249,7 +2275,7 @@ export function ProfileBypassRulesDialog({
}}
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
>
<LuX className="w-3.5 h-3.5" />
<LuX className="size-3.5" />
</button>
</div>
))}
+10 -8
View File
@@ -53,14 +53,16 @@ export function ProfilePasswordDialog({
const firstInputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (isOpen) {
setOldPassword("");
setPassword("");
setConfirm("");
setIsSubmitting(false);
setLockoutSecondsRemaining(null);
setTimeout(() => firstInputRef.current?.focus(), 0);
}
if (!isOpen) return;
setOldPassword("");
setPassword("");
setConfirm("");
setIsSubmitting(false);
setLockoutSecondsRemaining(null);
const handle = window.setTimeout(() => firstInputRef.current?.focus(), 0);
return () => {
window.clearTimeout(handle);
};
}, [isOpen]);
// Tick down the lockout timer
+1 -1
View File
@@ -237,7 +237,7 @@ export function ProfileSelectorDialog({
profile.browser,
);
return IconComponent ? (
<IconComponent className="w-4 h-4" />
<IconComponent className="size-4" />
) : null;
})()}
</div>
+4 -4
View File
@@ -188,7 +188,7 @@ export function ProfileSyncDialog({
{isCheckingConfig ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div>
) : (
<div className="grid gap-4 py-4">
@@ -216,7 +216,7 @@ export function ProfileSyncDialog({
disabled={isSaving}
className="grid gap-3"
>
<div className="flex items-start space-x-3">
<div className="flex items-start gap-x-3">
<RadioGroupItem value="Disabled" id="sync-disabled" />
<Label htmlFor="sync-disabled" className="cursor-pointer">
<span className="font-medium">
@@ -228,7 +228,7 @@ export function ProfileSyncDialog({
</Label>
</div>
<div className="flex items-start space-x-3">
<div className="flex items-start gap-x-3">
<RadioGroupItem value="Regular" id="sync-regular" />
<Label htmlFor="sync-regular" className="cursor-pointer">
<span className="font-medium">
@@ -240,7 +240,7 @@ export function ProfileSyncDialog({
</Label>
</div>
<div className="flex items-start space-x-3">
<div className="flex items-start gap-x-3">
<RadioGroupItem
value="Encrypted"
id="sync-encrypted"
+12 -6
View File
@@ -2,7 +2,7 @@
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
import { toast } from "sonner";
@@ -55,6 +55,7 @@ export function ProxyAssignmentDialog({
vpnConfigs = [],
}: ProxyAssignmentDialogProps) {
const { t } = useTranslation();
const proxyListboxId = useId();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectionType, setSelectionType] = useState<"none" | "proxy" | "vpn">(
"none",
@@ -183,6 +184,7 @@ export function ProxyAssignmentDialog({
variant="outline"
role="combobox"
aria-expanded={proxyPopoverOpen}
aria-controls={proxyListboxId}
className="w-full justify-between font-normal"
>
{(() => {
@@ -199,10 +201,14 @@ export function ProxyAssignmentDialog({
);
return proxy ? proxy.name : t("proxyAssignment.noneOption");
})()}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" sideOffset={8}>
<PopoverContent
id={proxyListboxId}
className="w-[240px] p-0"
sideOffset={8}
>
<Command>
<CommandInput
placeholder={t("proxyAssignment.searchPlaceholder")}
@@ -219,7 +225,7 @@ export function ProxyAssignmentDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectionType === "none"
? "opacity-100"
: "opacity-0",
@@ -243,7 +249,7 @@ export function ProxyAssignmentDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectionType === "proxy" &&
selectedId === proxy.id
? "opacity-100"
@@ -269,7 +275,7 @@ export function ProxyAssignmentDialog({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectionType === "vpn" && selectedId === vpn.id
? "opacity-100"
: "opacity-0",
+3 -3
View File
@@ -118,12 +118,12 @@ export function ProxyCheckButton({
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
className="size-7 p-0"
onClick={handleCheck}
disabled={isCurrentlyChecking || disabled}
>
{isCurrentlyChecking ? (
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
<div className="size-3 rounded-full border border-current animate-spin border-t-transparent" />
) : result?.is_valid && result.country_code ? (
<span className="relative inline-flex items-center justify-center">
<FlagIcon countryCode={result.country_code} className="h-2.5" />
@@ -132,7 +132,7 @@ export function ProxyCheckButton({
) : result && !result.is_valid ? (
<span className="text-destructive text-sm"></span>
) : (
<FiCheck className="w-3 h-3" />
<FiCheck className="size-3" />
)}
</Button>
</TooltipTrigger>
+5 -5
View File
@@ -108,13 +108,13 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
}}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<RadioGroupItem value="json" id="format-json" />
<Label htmlFor="format-json" className="cursor-pointer">
{t("proxies.exportDialog.json")}
</Label>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<RadioGroupItem value="txt" id="format-txt" />
<Label htmlFor="format-txt" className="cursor-pointer">
{t("proxies.exportDialog.txt")}
@@ -154,9 +154,9 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
className="flex gap-2 items-center"
>
{copied ? (
<LuCheck className="w-4 h-4" />
<LuCheck className="size-4" />
) : (
<LuCopy className="w-4 h-4" />
<LuCopy className="size-4" />
)}
{copied
? t("proxies.exportDialog.copied")
@@ -167,7 +167,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
disabled={!exportContent || isLoading}
className="flex gap-2 items-center"
>
<LuDownload className="w-4 h-4" />
<LuDownload className="size-4" />
{t("common.buttons.download")}
</RippleButton>
</DialogFooter>
+1 -1
View File
@@ -315,7 +315,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
}
}}
>
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<LuUpload className="size-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center">
{t("proxies.importDialog.dropzonePrompt")}
<br />
File diff suppressed because it is too large Load Diff
+28 -24
View File
@@ -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;
@@ -236,9 +244,11 @@ interface RailItem {
const TOP_ITEMS: RailItem[] = [
{ page: "profiles", Icon: LuUser, labelKey: "rail.profiles" },
{ page: "proxies", Icon: FiWifi, labelKey: "rail.proxies" },
{ page: "proxies", Icon: FiWifi, labelKey: "rail.network" },
{ page: "extensions", Icon: LuPuzzle, labelKey: "rail.extensions" },
{ page: "groups", Icon: LuUsers, labelKey: "rail.groups" },
{ page: "integrations", Icon: LuPlug, labelKey: "rail.integrations" },
{ page: "account", Icon: LuCloud, labelKey: "rail.account" },
];
interface MoreMenuItem {
@@ -256,16 +266,10 @@ const MORE_ITEMS: MoreMenuItem[] = [
hintKey: "rail.more.importProfileHint",
},
{
page: "integrations",
Icon: LuPlug,
labelKey: "rail.more.integrations",
hintKey: "rail.more.integrationsHint",
},
{
page: "account",
Icon: LuCloud,
labelKey: "rail.more.account",
hintKey: "rail.more.accountHint",
page: "shortcuts",
Icon: LuKeyboard,
labelKey: "rail.more.keyboardShortcuts",
hintKey: "rail.more.keyboardShortcutsHint",
},
];
@@ -290,7 +294,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
ref={logoRef}
type="button"
aria-label={t("header.donutLogo")}
className="grid place-items-center w-7 h-7 rounded-md cursor-pointer select-none text-foreground bg-transparent"
className="grid place-items-center size-7 rounded-md cursor-pointer select-none text-foreground bg-transparent"
onClick={handleClick}
onPointerDown={() => {
setIsPressed(true);
@@ -322,12 +326,12 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
"animate-[wiggle_0.3s_ease-in-out]",
)}
>
<Logo className="w-5 h-5 will-change-transform" />
<Logo className="size-5 will-change-transform" />
</span>
</span>
</button>
) : (
<div className="w-7 h-7" />
<div className="size-7" />
)}
<div className="w-5 h-px bg-border my-1" />
@@ -345,7 +349,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
aria-label={t(labelKey)}
aria-current={active ? "page" : undefined}
className={cn(
"relative grid place-items-center w-7 h-7 rounded-md transition-colors duration-100",
"relative grid place-items-center size-7 rounded-md transition-colors duration-100",
active
? "text-foreground bg-accent"
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
@@ -357,7 +361,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
/>
)}
<Icon className="w-3.5 h-3.5" />
<Icon className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{t(labelKey)}</TooltipContent>
@@ -377,13 +381,13 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
aria-label={t("rail.more.label")}
aria-expanded={moreOpen}
className={cn(
"grid place-items-center w-7 h-7 rounded-md transition-colors duration-100",
"grid place-items-center size-7 rounded-md transition-colors duration-100",
moreOpen
? "text-foreground bg-accent"
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
)}
>
<GoKebabHorizontal className="w-3.5 h-3.5" />
<GoKebabHorizontal className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{t("rail.more.label")}</TooltipContent>
@@ -399,7 +403,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
aria-label={t("rail.settings")}
aria-current={currentPage === "settings" ? "page" : undefined}
className={cn(
"relative grid place-items-center w-7 h-7 rounded-md transition-colors duration-100",
"relative grid place-items-center size-7 rounded-md transition-colors duration-100",
currentPage === "settings"
? "text-foreground bg-accent"
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
@@ -411,7 +415,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
/>
)}
<GoGear className="w-3.5 h-3.5" />
<GoGear className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{t("rail.settings")}</TooltipContent>
@@ -438,8 +442,8 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
}}
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md hover:bg-accent transition-colors duration-100 text-left"
>
<span className="grid place-items-center w-5 h-5 rounded bg-muted text-muted-foreground shrink-0">
<Icon className="w-3 h-3" />
<span className="grid place-items-center size-5 rounded bg-muted text-muted-foreground shrink-0">
<Icon className="size-3" />
</span>
<span className="flex flex-col min-w-0">
<span className="text-xs font-medium text-foreground truncate">
+7 -5
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuCheck, LuChevronsUpDown, LuDownload } from "react-icons/lu";
import { LoadingButton } from "@/components/loading-button";
@@ -44,6 +44,7 @@ export function ReleaseTypeSelector({
}: ReleaseTypeSelectorProps) {
const { t } = useTranslation();
const [popoverOpen, setPopoverOpen] = useState(false);
const listboxId = useId();
const effectivePlaceholder =
placeholder ?? t("releaseTypeSelector.placeholder");
@@ -91,13 +92,14 @@ export function ReleaseTypeSelector({
variant="outline"
role="combobox"
aria-expanded={popoverOpen}
aria-controls={listboxId}
className="justify-between w-full"
>
{selectedDisplayText}
<LuChevronsUpDown className="ml-2 w-4 h-4 opacity-50 shrink-0" />
<LuChevronsUpDown className="ml-2 size-4 opacity-50 shrink-0" />
</RippleButton>
</PopoverTrigger>
<PopoverContent className="p-0">
<PopoverContent id={listboxId} className="p-0">
<Command>
<CommandEmpty>
{t("releaseTypeSelector.noReleaseTypes")}
@@ -126,7 +128,7 @@ export function ReleaseTypeSelector({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
selectedReleaseType === option.type
? "opacity-100"
: "opacity-0",
@@ -187,7 +189,7 @@ export function ReleaseTypeSelector({
variant="outline"
className="w-full"
>
<LuDownload className="mr-2 w-4 h-4" />
<LuDownload className="mr-2 size-4" />
{isDownloading
? t("releaseTypeSelector.downloading")
: t("releaseTypeSelector.downloadBrowser")}
+121 -8
View File
@@ -24,6 +24,7 @@ import {
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
@@ -132,6 +133,9 @@ export function SettingsDialog({
const [e2eError, setE2eError] = useState("");
const [isSavingE2e, setIsSavingE2e] = useState(false);
const [isRemovingE2e, setIsRemovingE2e] = useState(false);
const [isVerifyE2eOpen, setIsVerifyE2eOpen] = useState(false);
const [verifyE2ePassword, setVerifyE2ePassword] = useState("");
const [isVerifyingE2e, setIsVerifyingE2e] = useState(false);
const [systemInfo, setSystemInfo] = useState<{
app_version: string;
os: string;
@@ -165,9 +169,9 @@ export function SettingsDialog({
const getPermissionIcon = useCallback((type: PermissionType) => {
switch (type) {
case "microphone":
return <BsMic className="w-4 h-4" />;
return <BsMic className="size-4" />;
case "camera":
return <BsCamera className="w-4 h-4" />;
return <BsCamera className="size-4" />;
}
}, []);
@@ -738,7 +742,7 @@ export function SettingsDialog({
<button
type="button"
aria-label={label}
className="w-8 h-8 rounded-md border shadow-sm cursor-pointer"
className="size-8 rounded-md border shadow-sm cursor-pointer"
style={{ backgroundColor: colorValue }}
/>
</PopoverTrigger>
@@ -887,7 +891,7 @@ export function SettingsDialog({
key={permission.permission_type}
className="flex justify-between items-center p-3 rounded-lg border"
>
<div className="flex items-center space-x-3">
<div className="flex items-center gap-x-3">
{getPermissionIcon(permission.permission_type)}
<div>
<div className="text-sm font-medium">
@@ -900,7 +904,7 @@ export function SettingsDialog({
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
{getStatusBadge(permission.isGranted)}
{!permission.isGranted && (
<LoadingButton
@@ -991,7 +995,18 @@ export function SettingsDialog({
{t("settings.encryption.passwordSetDescription")}
</span>
</div>
<div className="flex gap-2">
<div className="flex gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
disabled={isRemovingE2e}
onClick={() => {
setVerifyE2ePassword("");
setIsVerifyE2eOpen(true);
}}
>
{t("settings.encryption.validatePassword")}
</Button>
<Button
variant="outline"
size="sm"
@@ -1170,7 +1185,7 @@ export function SettingsDialog({
</Label>
{!isLinux && (
<div className="flex items-start space-x-3 p-3 rounded-lg border">
<div className="flex items-start gap-x-3 p-3 rounded-lg border">
<Checkbox
id="disable-auto-updates"
checked={settings.disable_auto_updates ?? false}
@@ -1192,7 +1207,7 @@ export function SettingsDialog({
</div>
)}
<div className="flex items-start space-x-3 p-3 rounded-lg border">
<div className="flex items-start gap-x-3 p-3 rounded-lg border">
<Checkbox
id="keep-decrypted-profiles-in-ram"
checked={settings.keep_decrypted_profiles_in_ram ?? false}
@@ -1317,6 +1332,104 @@ export function SettingsDialog({
isOpen={dnsBlocklistDialogOpen}
onClose={() => setDnsBlocklistDialogOpen(false)}
/>
<Dialog
open={isVerifyE2eOpen}
onOpenChange={(open) => {
if (!isVerifyingE2e) {
setIsVerifyE2eOpen(open);
if (!open) setVerifyE2ePassword("");
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("settings.encryption.validateDialog.title")}
</DialogTitle>
<DialogDescription>
{t("settings.encryption.validateDialog.description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Input
type="password"
placeholder={t("settings.encryption.passwordPlaceholder")}
value={verifyE2ePassword}
autoFocus
onChange={(e) => setVerifyE2ePassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && verifyE2ePassword.length > 0) {
e.preventDefault();
void (async () => {
setIsVerifyingE2e(true);
try {
const ok = await invoke<boolean>("verify_e2e_password", {
password: verifyE2ePassword,
});
if (ok) {
showSuccessToast(
t("settings.encryption.validateDialog.matchToast"),
);
setIsVerifyE2eOpen(false);
setVerifyE2ePassword("");
} else {
showErrorToast(
t("settings.encryption.validateDialog.mismatchToast"),
);
}
} catch (error) {
showErrorToast(String(error));
} finally {
setIsVerifyingE2e(false);
}
})();
}
}}
/>
</div>
<DialogFooter>
<Button
variant="outline"
disabled={isVerifyingE2e}
onClick={() => {
setIsVerifyE2eOpen(false);
setVerifyE2ePassword("");
}}
>
{t("common.buttons.cancel")}
</Button>
<LoadingButton
isLoading={isVerifyingE2e}
disabled={verifyE2ePassword.length === 0}
onClick={async () => {
setIsVerifyingE2e(true);
try {
const ok = await invoke<boolean>("verify_e2e_password", {
password: verifyE2ePassword,
});
if (ok) {
showSuccessToast(
t("settings.encryption.validateDialog.matchToast"),
);
setIsVerifyE2eOpen(false);
setVerifyE2ePassword("");
} else {
showErrorToast(
t("settings.encryption.validateDialog.mismatchToast"),
);
}
} catch (error) {
showErrorToast(String(error));
} finally {
setIsVerifyingE2e(false);
}
}}
>
{t("settings.encryption.validateDialog.submit")}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -303,7 +303,7 @@ export function SharedCamoufoxConfigForm({
{/* Randomize Fingerprint Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="randomize-fingerprint"
checked={config.randomize_fingerprint_on_launch ?? false}
@@ -323,7 +323,7 @@ export function SharedCamoufoxConfigForm({
{/* Automatic Location Configuration */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="auto-location-advanced"
checked={isAutoLocationEnabled}
@@ -367,7 +367,7 @@ export function SharedCamoufoxConfigForm({
<div className="space-y-3">
<Label>{t("fingerprint.blockingOptions")}</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="block-images"
checked={config.block_images ?? false}
@@ -379,7 +379,7 @@ export function SharedCamoufoxConfigForm({
{t("fingerprint.blockImages")}
</Label>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="block-webrtc"
checked={config.block_webrtc ?? false}
@@ -391,7 +391,7 @@ export function SharedCamoufoxConfigForm({
{t("fingerprint.blockWebRTC")}
</Label>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="block-webgl"
checked={config.block_webgl ?? false}
@@ -1025,7 +1025,7 @@ export function SharedCamoufoxConfigForm({
<Label>{t("fingerprint.battery")}</Label>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="battery-charging"
checked={fingerprintConfig["battery:charging"] ?? false}
@@ -1176,7 +1176,7 @@ export function SharedCamoufoxConfigForm({
{/* Randomize Fingerprint Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="randomize-fingerprint-auto"
checked={config.randomize_fingerprint_on_launch ?? false}
@@ -1199,7 +1199,7 @@ export function SharedCamoufoxConfigForm({
{/* Automatic Location Configuration */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="auto-location"
checked={isAutoLocationEnabled}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { useTranslation } from "react-i18next";
import {
formatGroupShortcut,
formatShortcut,
SHORTCUTS,
type ShortcutDef,
} from "@/lib/shortcuts";
interface GroupTarget {
id: string;
name: string;
}
interface ShortcutsPageProps {
/** Ordered list — first 9 entries display their Mod+digit binding. */
groupTargets: GroupTarget[];
}
function Tokens({ tokens }: { tokens: string[] }) {
return (
<div className="flex items-center gap-1">
{tokens.map((tok, i) => (
<kbd
key={i}
className="inline-flex items-center justify-center min-w-[1.5rem] h-6 px-1.5 rounded border border-border bg-muted text-[11px] font-medium text-foreground"
>
{tok}
</kbd>
))}
</div>
);
}
function ShortcutTokens({ shortcut }: { shortcut: ShortcutDef }) {
return <Tokens tokens={formatShortcut(shortcut)} />;
}
export function ShortcutsPage({ groupTargets }: ShortcutsPageProps) {
const { t } = useTranslation();
const sections: Array<{ key: ShortcutDef["group"]; titleKey: string }> = [
{ key: "navigation", titleKey: "commandPalette.groups.navigation" },
{ key: "actions", titleKey: "commandPalette.groups.actions" },
];
const digitGroups = groupTargets.slice(0, 9);
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto px-6 pt-4 pb-8">
<div className="max-w-3xl w-full mx-auto flex flex-col gap-6">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-semibold">{t("shortcutsPage.title")}</h1>
<p className="text-xs text-muted-foreground">
{t("shortcutsPage.description")}
</p>
</header>
{sections.map(({ key, titleKey }) => {
const items = SHORTCUTS.filter((s) => s.group === key);
if (items.length === 0) return null;
return (
<section key={key} className="flex flex-col gap-2">
<h2 className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t(titleKey)}
</h2>
<div className="rounded-md border bg-card divide-y divide-border">
{items.map((s) => (
<div
key={s.id}
className="flex items-center justify-between gap-4 px-3 py-2"
>
<span className="text-sm">{t(s.labelKey)}</span>
<ShortcutTokens shortcut={s} />
</div>
))}
</div>
</section>
);
})}
{digitGroups.length > 0 ? (
<section className="flex flex-col gap-2">
<h2 className="text-[10px] uppercase tracking-wide text-muted-foreground">
{t("commandPalette.groups.profileGroups")}
</h2>
<div className="rounded-md border bg-card divide-y divide-border">
{digitGroups.map((target, i) => (
<div
key={target.id}
className="flex items-center justify-between gap-4 px-3 py-2"
>
<span className="text-sm">{target.name}</span>
<Tokens tokens={formatGroupShortcut(i + 1)} />
</div>
))}
</div>
</section>
) : null}
</div>
</div>
);
}
+1 -1
View File
@@ -96,7 +96,7 @@ export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
{isLoading ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div>
) : (
<div className="py-4">
+8 -8
View File
@@ -248,7 +248,7 @@ export function SyncConfigDialog({
{isLoggedIn && user ? (
<div className="grid gap-4 py-4">
<div className="flex gap-2 items-center text-sm">
<div className="w-2 h-2 rounded-full bg-success" />
<div className="size-2 rounded-full bg-success" />
{t("sync.cloud.connected")}
</div>
@@ -353,7 +353,7 @@ export function SyncConfigDialog({
<TabsContent value="cloud">
{isCloudLoading ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div>
) : (
<div className="grid gap-4 py-4">
@@ -373,7 +373,7 @@ export function SyncConfigDialog({
<TabsContent value="self-hosted">
{isLoading ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
</div>
) : (
<div className="grid gap-4 py-4">
@@ -419,9 +419,9 @@ export function SyncConfigDialog({
}
>
{showToken ? (
<LuEyeOff className="w-4 h-4 text-muted-foreground hover:text-foreground" />
<LuEyeOff className="size-4 text-muted-foreground hover:text-foreground" />
) : (
<LuEye className="w-4 h-4 text-muted-foreground hover:text-foreground" />
<LuEye className="size-4 text-muted-foreground hover:text-foreground" />
)}
</button>
</TooltipTrigger>
@@ -434,19 +434,19 @@ export function SyncConfigDialog({
{connectionStatus === "testing" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-4 h-4 rounded-full border-2 border-current animate-spin border-t-transparent" />
<div className="size-4 rounded-full border-2 border-current animate-spin border-t-transparent" />
{t("sync.status.syncing")}
</div>
)}
{connectionStatus === "connected" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-success" />
<div className="size-2 rounded-full bg-success" />
{t("sync.status.connected")}
</div>
)}
{connectionStatus === "error" && (
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-destructive" />
<div className="size-2 rounded-full bg-destructive" />
{t("sync.status.disconnected")}
</div>
)}
+20 -16
View File
@@ -108,24 +108,28 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
// Re-apply custom theme after mount
useEffect(() => {
if (!isLoading && theme === "custom") {
const reapplyCustomTheme = async () => {
try {
const { invoke } = await import("@tauri-apps/api/core");
const settings = await invoke<AppSettings>("get_app_settings");
if (settings?.theme === "custom" && settings.custom_theme) {
applyThemeColors(settings.custom_theme);
}
} catch (error) {
console.warn("Failed to reapply custom theme:", error);
}
};
setTimeout(() => {
void reapplyCustomTheme();
}, 100);
} else if (!isLoading) {
if (isLoading) return;
if (theme !== "custom") {
clearThemeColors();
return;
}
const reapplyCustomTheme = async () => {
try {
const { invoke } = await import("@tauri-apps/api/core");
const settings = await invoke<AppSettings>("get_app_settings");
if (settings?.theme === "custom" && settings.custom_theme) {
applyThemeColors(settings.custom_theme);
}
} catch (error) {
console.warn("Failed to reapply custom theme:", error);
}
};
const handle = window.setTimeout(() => {
void reapplyCustomTheme();
}, 100);
return () => {
window.clearTimeout(handle);
};
}, [isLoading, theme]);
// Listen for system theme changes when in "system" mode
+5 -4
View File
@@ -23,6 +23,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
@@ -398,7 +399,7 @@ export function TrafficDetailsDialog({
<div className="flex items-center justify-center gap-6 mt-2">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded"
className="size-3 rounded"
style={{ backgroundColor: "var(--chart-1)" }}
/>
<span className="text-xs text-muted-foreground">
@@ -407,7 +408,7 @@ export function TrafficDetailsDialog({
</div>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded"
className="size-3 rounded"
style={{ backgroundColor: "var(--chart-2)" }}
/>
<span className="text-xs text-muted-foreground">
@@ -590,7 +591,7 @@ export function TrafficDetailsDialog({
<h3 className="text-sm font-medium mb-2">
{t("traffic.uniqueIps", { count: stats.unique_ips.length })}
</h3>
<div className="border rounded-md p-3 max-h-[120px] overflow-y-auto">
<FadingScrollArea className="p-3 max-h-[120px]">
<div className="flex flex-wrap gap-1.5">
{stats.unique_ips.map((ip) => (
<span
@@ -601,7 +602,7 @@ export function TrafficDetailsDialog({
</span>
))}
</div>
</div>
</FadingScrollArea>
</div>
)}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { motion } from "motion/react";
import { Switch as SwitchPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
const MotionThumb = motion.create(SwitchPrimitive.Thumb);
type AnimatedSwitchProps = React.ComponentProps<typeof SwitchPrimitive.Root>;
/**
* Toggle switch with a thumb that slides between the off (left) and on
* (right) positions and squashes wider while pressed. Animated via Framer
* Motion no layout shift when the parent's width changes, and the
* pressed state is purely visual so external onCheckedChange semantics
* stay identical to a Radix Switch.
*/
function AnimatedSwitch({ className, ...props }: AnimatedSwitchProps) {
return (
<SwitchPrimitive.Root
data-slot="animated-switch"
className={cn(
"peer relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent",
"bg-input data-[state=checked]:bg-primary",
"transition-colors duration-200 ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<MotionThumb
data-slot="animated-switch-thumb"
className={cn(
"pointer-events-none block size-4 rounded-full shadow-sm ring-0",
"bg-background data-[state=checked]:bg-primary-foreground",
)}
layout
transition={{ type: "spring", stiffness: 700, damping: 32, mass: 0.5 }}
whileTap={{ width: 22 }}
style={{ marginLeft: 2, marginRight: 2 }}
/>
</SwitchPrimitive.Root>
);
}
export type { AnimatedSwitchProps };
export { AnimatedSwitch };
+156
View File
@@ -0,0 +1,156 @@
"use client";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { motion } from "motion/react";
import * as React from "react";
import { useControlledState } from "@/hooks/use-controlled-state";
import { cn } from "@/lib/utils";
interface AnimatedTabsContextValue {
activeValue: string | undefined;
hoveredValue: string | null;
setHoveredValue: (value: string | null) => void;
indicatorId: string;
}
const AnimatedTabsContext =
React.createContext<AnimatedTabsContextValue | null>(null);
function useAnimatedTabs() {
const ctx = React.useContext(AnimatedTabsContext);
if (!ctx) {
throw new Error(
"AnimatedTabsTrigger must be rendered inside <AnimatedTabs>",
);
}
return ctx;
}
type AnimatedTabsProps = React.ComponentProps<typeof TabsPrimitive.Root>;
function AnimatedTabs({
value: valueProp,
defaultValue,
onValueChange,
children,
...props
}: AnimatedTabsProps) {
const [activeValue, setActiveValue] = useControlledState({
value: valueProp,
defaultValue,
onChange: onValueChange,
});
const [hoveredValue, setHoveredValue] = React.useState<string | null>(null);
const indicatorId = React.useId();
return (
<AnimatedTabsContext.Provider
value={{
activeValue,
hoveredValue,
setHoveredValue,
indicatorId,
}}
>
<TabsPrimitive.Root
data-slot="animated-tabs"
value={activeValue}
defaultValue={defaultValue}
onValueChange={setActiveValue}
{...props}
>
{children}
</TabsPrimitive.Root>
</AnimatedTabsContext.Provider>
);
}
type AnimatedTabsListProps = React.ComponentProps<typeof TabsPrimitive.List>;
function AnimatedTabsList({
className,
onMouseLeave,
...props
}: AnimatedTabsListProps) {
const { setHoveredValue } = useAnimatedTabs();
return (
<TabsPrimitive.List
data-slot="animated-tabs-list"
className={cn(
"relative inline-flex items-center gap-1 rounded-md p-0",
className,
)}
onMouseLeave={(event) => {
setHoveredValue(null);
onMouseLeave?.(event);
}}
{...props}
/>
);
}
type AnimatedTabsTriggerProps = React.ComponentProps<
typeof TabsPrimitive.Trigger
>;
function AnimatedTabsTrigger({
value,
className,
children,
onMouseEnter,
...props
}: AnimatedTabsTriggerProps) {
const { activeValue, hoveredValue, setHoveredValue, indicatorId } =
useAnimatedTabs();
// The visible pill follows hover when present, otherwise sits on the
// active tab. Framer's `layoutId` handles the slide animation between
// mounted instances; only the trigger whose `value` matches `shownValue`
// renders the indicator, so the transition is a single-element move.
const shownValue = hoveredValue ?? activeValue;
const showIndicator = shownValue === value;
const isActive = activeValue === value;
return (
<TabsPrimitive.Trigger
data-slot="animated-tabs-trigger"
value={value}
onMouseEnter={(event) => {
setHoveredValue(value);
onMouseEnter?.(event);
}}
className={cn(
"relative isolate inline-flex h-7 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-sm font-medium transition-colors duration-150",
"text-muted-foreground hover:text-foreground",
isActive && "text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
>
{showIndicator && (
<motion.span
layoutId={`animated-tabs-indicator-${indicatorId}`}
className="absolute inset-0 -z-10 rounded-md bg-accent"
transition={{ type: "spring", stiffness: 360, damping: 32 }}
/>
)}
{children}
</TabsPrimitive.Trigger>
);
}
const AnimatedTabsContent = TabsPrimitive.Content;
export type {
AnimatedTabsListProps,
AnimatedTabsProps,
AnimatedTabsTriggerProps,
};
export {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
};
+2 -2
View File
@@ -229,7 +229,7 @@ const ChartTooltipContent = React.forwardRef<
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"size-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
@@ -321,7 +321,7 @@ const ChartLegendContent = React.forwardRef<
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
className="size-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
+3 -3
View File
@@ -245,7 +245,7 @@ export const ColorPickerSelection = memo(
{...props}
>
<div
className="absolute w-4 h-4 rounded-full border-2 border-white -translate-x-1/2 -translate-y-1/2 pointer-events-none"
className="absolute size-4 rounded-full border-2 border-white -translate-x-1/2 -translate-y-1/2 pointer-events-none"
style={{
left: `${positionX * 100}%`,
top: `${positionY * 100}%`,
@@ -281,7 +281,7 @@ export const ColorPickerHue = ({
<Slider.Track className="relative my-0.5 h-3 w-full grow rounded-full bg-[linear-gradient(90deg,#FF0000,#FFFF00,#00FF00,#00FFFF,#0000FF,#FF00FF,#FF0000)]">
<Slider.Range className="absolute h-full" />
</Slider.Track>
<Slider.Thumb className="block w-4 h-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
<Slider.Thumb className="block size-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</Slider.Root>
);
};
@@ -315,7 +315,7 @@ export const ColorPickerAlpha = ({
<div className="absolute inset-0 bg-gradient-to-r from-transparent rounded-full to-black/50" />
<Slider.Range className="absolute h-full bg-transparent rounded-full" />
</Slider.Track>
<Slider.Thumb className="block w-4 h-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
<Slider.Thumb className="block size-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</Slider.Root>
);
};
+5 -3
View File
@@ -47,6 +47,7 @@ export function Combobox({
}: ComboboxProps) {
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const listboxId = React.useId();
const resolvedPlaceholder = placeholder ?? t("common.buttons.select");
const resolvedSearchPlaceholder =
@@ -59,16 +60,17 @@ export function Combobox({
variant="outline"
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
disabled={disabled}
className={cn("w-full justify-between", className)}
>
{value
? options.find((option) => option.value === value)?.label
: resolvedPlaceholder}
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<PopoverContent id={listboxId} className="w-full p-0">
<Command>
<CommandInput placeholder={resolvedSearchPlaceholder} />
<CommandList>
@@ -85,7 +87,7 @@ export function Combobox({
>
<LuCheck
className={cn(
"mr-2 h-4 w-4",
"mr-2 size-4",
value === option.value ? "opacity-100" : "opacity-0",
)}
/>
+9 -1
View File
@@ -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>
+2 -2
View File
@@ -55,12 +55,12 @@ export function CopyToClipboard({
{copied ? t("common.srOnly.copied") : t("common.srOnly.copy")}
</span>
<LuCopy
className={`h-4 w-4 transition-all duration-150 ${
className={`size-4 transition-all duration-150 ${
copied ? "scale-0" : "scale-100"
}`}
/>
<LuCheck
className={`absolute inset-0 m-auto h-4 w-4 text-foreground transition-all duration-150 ${
className={`absolute inset-0 m-auto size-4 text-foreground transition-all duration-150 ${
copied ? "scale-100" : "scale-0"
}`}
/>
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { type HTMLAttributes, useRef } from "react";
import { useScrollFade } from "@/hooks/use-scroll-fade";
import { cn } from "@/lib/utils";
export type FadingScrollAreaProps = HTMLAttributes<HTMLDivElement>;
/**
* Scrollable container with top/bottom fade overlays. The fades only become
* visible when the matching direction is actually scrollable. Use in place
* of `<div className="border rounded-md max-h-[...] overflow-auto">` for
* lists that should match the borderless aesthetic of the profile table.
*/
export function FadingScrollArea({
className,
children,
...props
}: FadingScrollAreaProps) {
const ref = useRef<HTMLDivElement>(null);
useScrollFade(ref);
return (
<div
ref={ref}
className={cn("overflow-y-auto scroll-fade", className)}
{...props}
>
{children}
</div>
);
}
+2 -2
View File
@@ -28,13 +28,13 @@ const RadioGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"aspect-square size-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<LuCircle className="h-2.5 w-2.5 fill-current text-current" />
<LuCircle className="size-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
+4 -4
View File
@@ -70,18 +70,18 @@ export function VpnCheckButton({
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
className="size-7 p-0"
onClick={handleCheck}
disabled={isCurrentlyChecking || disabled}
>
{isCurrentlyChecking ? (
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
<div className="size-3 rounded-full border border-current animate-spin border-t-transparent" />
) : result?.is_valid ? (
<FiCheck className="w-3 h-3 text-success" />
<FiCheck className="size-3 text-success" />
) : result && !result.is_valid ? (
<span className="text-destructive text-sm"></span>
) : (
<FiCheck className="w-3 h-3" />
<FiCheck className="size-3" />
)}
</Button>
</TooltipTrigger>
+3 -3
View File
@@ -219,7 +219,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
}
}}
>
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<LuUpload className="size-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center">
{t("vpns.import.dropzonePrompt")}
</p>
@@ -244,7 +244,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
{step === "vpn-preview" && vpnPreview && (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg">
<LuShield className="w-8 h-8 text-primary" />
<LuShield className="size-8 text-primary" />
<div>
<div className="font-medium">
{t("vpns.import.configurationLabel", {
@@ -292,7 +292,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
>
{vpnImportResult.success ? (
<div className="flex items-center gap-3">
<LuShield className="w-8 h-8 text-success" />
<LuShield className="size-8 text-success" />
<div>
<div className="font-medium text-success">
{t("vpns.import.importedSuccess")}
+5 -5
View File
@@ -228,7 +228,7 @@ export function WayfernConfigForm({
{/* Randomize Fingerprint Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="randomize-fingerprint"
checked={config.randomize_fingerprint_on_launch ?? false}
@@ -248,7 +248,7 @@ export function WayfernConfigForm({
{/* Automatic Location Configuration */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="auto-location-advanced"
checked={isAutoLocationEnabled}
@@ -954,7 +954,7 @@ export function WayfernConfigForm({
<Label>{t("fingerprint.battery")}</Label>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="battery-charging"
checked={fingerprintConfig.batteryCharging ?? false}
@@ -1133,7 +1133,7 @@ export function WayfernConfigForm({
{/* Randomize Fingerprint Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="randomize-fingerprint-auto"
checked={config.randomize_fingerprint_on_launch ?? false}
@@ -1156,7 +1156,7 @@ export function WayfernConfigForm({
{/* Automatic Location Configuration */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="auto-location"
checked={isAutoLocationEnabled}
@@ -79,7 +79,7 @@ export function WindowResizeWarningDialog({
<p className="text-sm text-muted-foreground">{description}</p>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-2">
<Checkbox
id="dont-show-again"
checked={dontShowAgain}
+11 -8
View File
@@ -38,6 +38,7 @@ export function useGroupEvents() {
// Initial load and event listeners setup
useEffect(() => {
let groupsUnlisten: (() => void) | undefined;
let profilesUnlisten: (() => void) | undefined;
const setupListeners = async () => {
try {
@@ -51,19 +52,13 @@ export function useGroupEvents() {
});
// Also listen for profile changes since groups show profile counts
const profilesUnlisten = await listen("profiles-changed", () => {
profilesUnlisten = await listen("profiles-changed", () => {
console.log(
"Received profiles-changed event, reloading groups for updated counts",
);
void loadGroups();
});
// Store both listeners for cleanup
groupsUnlisten = () => {
groupsUnlisten?.();
profilesUnlisten();
};
console.log("Group event listeners set up successfully");
} catch (err) {
console.error("Failed to setup group event listeners:", err);
@@ -79,9 +74,17 @@ export function useGroupEvents() {
void setupListeners();
// Cleanup listeners on unmount
// Cleanup listeners on unmount.
// NOTE: the previous version stored both unlisten fns by reassigning
// `groupsUnlisten` to a wrapper that called itself, which produced a
// `Maximum call stack size exceeded` crash whenever this effect tore
// down. React's reconciler then bailed out mid-commit and left stale
// overlay nodes in the DOM, blocking every subsequent click in the
// window. Holding the two unlisten fns in separate locals avoids both
// problems.
return () => {
if (groupsUnlisten) groupsUnlisten();
if (profilesUnlisten) profilesUnlisten();
};
}, [loadGroups]);
+113 -34
View File
@@ -32,7 +32,8 @@
"downloading": "Downloading...",
"minimize": "Minimize",
"saving": "Saving…",
"saved": "Saved"
"saved": "Saved",
"copied": "Copied"
},
"status": {
"active": "Active",
@@ -158,7 +159,15 @@
"passwordSaved": "Encryption password set",
"passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 8 characters",
"requiresProOrOwner": "Profile encryption is available for Pro users and team owners."
"requiresProOrOwner": "Profile encryption is available for Pro users and team owners.",
"validatePassword": "Validate",
"validateDialog": {
"title": "Validate Encryption Password",
"description": "Enter your encryption password to verify it matches the one stored on this device.",
"submit": "Validate",
"matchToast": "Password is correct",
"mismatchToast": "Password does not match"
}
},
"commercial": {
"title": "Commercial License",
@@ -376,6 +385,9 @@
"deleteFailed": "Failed to delete proxy",
"deleteTitle": "Delete Proxy",
"deleteDescription": "This action cannot be undone. This will permanently delete the proxy \"{{name}}\".",
"newProxy": "New proxy",
"newVpn": "New VPN",
"protocolCol": "Protocol",
"title": "Proxies & VPNs"
},
"add": "Add Proxy",
@@ -480,6 +492,13 @@
"continueButton": "Continue",
"doneButton": "Done",
"failed": "Failed to import proxies"
},
"bulkDelete": {
"proxiesTitle": "Delete Selected Proxies",
"proxiesDescription": "This action cannot be undone. This will permanently delete {{count}} proxy(s): {{names}}.",
"vpnsTitle": "Delete Selected VPNs",
"vpnsDescription": "This action cannot be undone. This will permanently delete {{count}} VPN(s): {{names}}.",
"confirmButton": "Delete {{count}}"
}
},
"groups": {
@@ -488,10 +507,8 @@
"add": "Add Group",
"edit": "Edit Group",
"delete": "Delete Group",
"defaultGroup": "Default",
"defaultGroupNoGroup": "Default (No Group)",
"moveToDefault": "Move profiles to Default group",
"noGroupDescription": "Profiles without a group will appear in the \"Default\" group.",
"moveToDefault": "Remove profiles from group",
"noGroupDescription": "Profiles without a group appear in the \"All\" filter.",
"assignSuccess": "Successfully assigned {{count}} profile(s) to {{group}}",
"noGroups": "No groups created",
"noGroupsDescription": "Create a group to organize your profiles.",
@@ -521,7 +538,6 @@
"loadingProfiles": "Loading associated profiles...",
"associatedProfiles": "Associated Profiles ({{count}})",
"whatToDoWithProfiles": "What should happen to these profiles?",
"moveToDefaultOption": "Move profiles to Default group",
"deleteAlongWithGroup": "Delete profiles along with the group",
"noAssociatedProfiles": "This group has no associated profiles.",
"deleteGroup": "Delete Group",
@@ -530,7 +546,10 @@
"unknownGroup": "Unknown Group",
"profileGroupsAriaLabel": "Profile groups",
"loading": "Loading groups...",
"all": "All"
"all": "All",
"noGroup": "No group",
"pageTitle": "Profile groups",
"pageDescription": "Profile groups let you organize browsers by client, environment, or use case. Sync groups across devices to share them."
},
"sync": {
"mode": {
@@ -637,19 +656,20 @@
"tokenCopied": "Token copied",
"url": "MCP Server URL",
"urlCopied": "URL copied",
"claudeDesktopTitle": "Claude Desktop",
"claudeDesktopHint": "Configures claude_desktop_config.json automatically",
"addToClaudeDesktop": "Add to Claude Desktop",
"removeFromClaudeDesktop": "Remove from Claude Desktop",
"addedToClaudeDesktop": "Added to Claude Desktop. Restart Claude Desktop and enable the extension in Settings.",
"removedFromClaudeDesktop": "Removed from Claude Desktop config. Please restart Claude Desktop.",
"claudeCodeTitle": "Claude Code",
"addToClaudeCode": "Add to Claude Code",
"removeFromClaudeCode": "Remove from Claude Code",
"addedToClaudeCode": "Added to Claude Code",
"removedFromClaudeCode": "Removed from Claude Code",
"config": "MCP Configuration",
"copyConfig": "Copy Configuration"
"copyConfig": "Copy Configuration",
"clientsLabel": "Clients",
"connected": "Connected",
"add": "Add",
"addedToClient": "Added to {{name}}",
"removedFromClient": "Removed from {{name}}",
"removeAriaLabel": "Remove from {{name}}",
"category": {
"desktopApp": "Desktop app",
"cli": "CLI",
"editor": "Editor",
"editorExt": "Editor ext"
}
},
"tabApi": "Local API",
"tabMcp": "MCP (AI Assistants)",
@@ -675,7 +695,9 @@
"mcpStarted": "MCP server started on port {{port}}",
"mcpStopped": "MCP server stopped",
"mcpToggleFailed": "Failed to toggle MCP server",
"openSettings": "Open Integrations Settings"
"openSettings": "Open Integrations Settings",
"apiRunningOn": "Running on",
"apiExampleRequest": "Example request"
},
"import": {
"title": "Import Profile",
@@ -1181,6 +1203,7 @@
"empty": "No extensions uploaded yet.",
"noGroups": "No extension groups created yet.",
"createGroup": "Create Group",
"newGroup": "New group",
"addToGroup": "Add extension...",
"removeFromGroup": "Remove from group",
"deleteGroup": "Delete group",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "Enable sync",
"syncDisableTooltip": "Disable sync",
"loadGroupsFailed": "Failed to load extension groups",
"assignGroupFailed": "Failed to assign extension group"
"assignGroupFailed": "Failed to assign extension group",
"bulkDelete": {
"extensionsTitle": "Delete extensions",
"extensionsDescription": "Delete {{count}} extensions? {{names}}",
"groupsTitle": "Delete extension groups",
"groupsDescription": "Delete {{count}} extension groups? {{names}}",
"confirmButton": "Delete"
}
},
"pro": {
"badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "Waiting to sync",
"errorWith": "Sync error: {{error}}",
"error": "Sync error",
"notSynced": "Not synced"
"notSynced": "Not synced",
"enable": "Enable sync",
"disable": "Disable sync",
"lockedInUse": "Sync is locked while in use by a synced profile",
"bulkToggle": "Toggle sync"
},
"groupManagement": {
"description": "Manage your profile groups",
@@ -1387,7 +1421,13 @@
"syncCannotDisable": "Sync cannot be disabled while this group is used by synced profiles",
"editGroupTooltip": "Edit group",
"deleteGroupTooltip": "Delete group",
"loadFailed": "Failed to load groups"
"loadFailed": "Failed to load groups",
"bulkDelete": {
"title": "Delete groups",
"description": "Are you sure you want to delete {{count}} groups? {{names}}. Profiles will be moved to Default.",
"description_one": "Are you sure you want to delete {{count}} group? {{names}}. Profiles will be moved to Default.",
"confirmButton": "Delete groups"
}
},
"proxyAssignment": {
"title": "Assign Proxy / VPN",
@@ -1721,7 +1761,14 @@
"modes": {
"set": "Set",
"change": "Change",
"remove": "Remove"
"remove": "Remove",
"validate": "Validate"
},
"verifyDialog": {
"title": "Validate Profile Password",
"description": "Enter the profile password to confirm it matches the one stored on disk.",
"submit": "Validate",
"matchToast": "Password is correct"
}
},
"backendErrors": {
@@ -1749,7 +1796,6 @@
},
"rail": {
"profiles": "Profiles",
"proxies": "Proxies",
"extensions": "Extensions",
"groups": "Groups",
"settings": "Settings",
@@ -1758,21 +1804,23 @@
"closeAriaLabel": "Close menu",
"importProfile": "Import profile",
"importProfileHint": "Bring profiles from another tool",
"integrations": "Integrations",
"integrationsHint": "Slack, MCP, automations",
"account": "Account",
"accountHint": "Cloud, billing, sign-in"
}
"keyboardShortcuts": "Keyboard shortcuts",
"keyboardShortcutsHint": "View all shortcuts"
},
"network": "Network",
"integrations": "Integrations",
"account": "Account"
},
"pageTitle": {
"proxies": "Proxies",
"proxies": "Network",
"extensions": "Extensions",
"groups": "Groups",
"vpns": "VPNs",
"vpns": "Network",
"settings": "Settings",
"integrations": "Integrations",
"account": "Account",
"import": "Import profile"
"import": "Import profile",
"shortcuts": "Keyboard shortcuts"
},
"encryption": {
"required": {
@@ -1825,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"
}
}
+113 -34
View File
@@ -32,7 +32,8 @@
"downloading": "Descargando...",
"minimize": "Minimizar",
"saving": "Guardando…",
"saved": "Guardado"
"saved": "Guardado",
"copied": "Copiado"
},
"status": {
"active": "Activo",
@@ -158,7 +159,15 @@
"passwordSaved": "Contraseña de cifrado establecida",
"passwordMismatch": "Las contraseñas no coinciden",
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres",
"requiresProOrOwner": "El cifrado de perfiles está disponible para usuarios Pro y propietarios de equipos."
"requiresProOrOwner": "El cifrado de perfiles está disponible para usuarios Pro y propietarios de equipos.",
"validatePassword": "Validar",
"validateDialog": {
"title": "Validar contraseña de cifrado",
"description": "Introduce tu contraseña de cifrado para verificar que coincide con la almacenada en este dispositivo.",
"submit": "Validar",
"matchToast": "La contraseña es correcta",
"mismatchToast": "La contraseña no coincide"
}
},
"commercial": {
"title": "Licencia Comercial",
@@ -376,6 +385,9 @@
"deleteFailed": "Error al eliminar el proxy",
"deleteTitle": "Eliminar proxy",
"deleteDescription": "Esta acción no se puede deshacer. Se eliminará permanentemente el proxy \"{{name}}\".",
"newProxy": "Nuevo proxy",
"newVpn": "Nueva VPN",
"protocolCol": "Protocolo",
"title": "Proxies y VPN"
},
"add": "Agregar Proxy",
@@ -480,6 +492,13 @@
"continueButton": "Continuar",
"doneButton": "Hecho",
"failed": "Error al importar los proxies"
},
"bulkDelete": {
"proxiesTitle": "Eliminar proxies seleccionados",
"proxiesDescription": "Esta acción no se puede deshacer. Se eliminarán permanentemente {{count}} proxy(s): {{names}}.",
"vpnsTitle": "Eliminar VPN seleccionadas",
"vpnsDescription": "Esta acción no se puede deshacer. Se eliminarán permanentemente {{count}} VPN(s): {{names}}.",
"confirmButton": "Eliminar {{count}}"
}
},
"groups": {
@@ -488,10 +507,8 @@
"add": "Agregar Grupo",
"edit": "Editar Grupo",
"delete": "Eliminar Grupo",
"defaultGroup": "Predeterminado",
"defaultGroupNoGroup": "Predeterminado (Sin Grupo)",
"moveToDefault": "Mover perfiles al grupo Predeterminado",
"noGroupDescription": "Los perfiles sin grupo aparecerán en el grupo \"Predeterminado\".",
"moveToDefault": "Quitar perfiles del grupo",
"noGroupDescription": "Los perfiles sin grupo aparecen en el filtro «Todos».",
"assignSuccess": "Se asignaron {{count}} perfil(es) a {{group}} exitosamente",
"noGroups": "No hay grupos creados",
"noGroupsDescription": "Crea un grupo para organizar tus perfiles.",
@@ -521,7 +538,6 @@
"loadingProfiles": "Cargando perfiles asociados...",
"associatedProfiles": "Perfiles Asociados ({{count}})",
"whatToDoWithProfiles": "¿Qué hacer con estos perfiles?",
"moveToDefaultOption": "Mover perfiles al grupo Predeterminado",
"deleteAlongWithGroup": "Eliminar perfiles junto con el grupo",
"noAssociatedProfiles": "Este grupo no tiene perfiles asociados.",
"deleteGroup": "Eliminar Grupo",
@@ -530,7 +546,10 @@
"unknownGroup": "Grupo desconocido",
"profileGroupsAriaLabel": "Grupos de perfiles",
"loading": "Cargando grupos...",
"all": "Todos"
"all": "Todos",
"noGroup": "Sin grupo",
"pageTitle": "Grupos de perfiles",
"pageDescription": "Los grupos de perfiles te permiten organizar los navegadores por cliente, entorno o caso de uso. Sincroniza los grupos entre dispositivos para compartirlos."
},
"sync": {
"mode": {
@@ -637,19 +656,20 @@
"tokenCopied": "Token copiado",
"url": "URL del servidor MCP",
"urlCopied": "URL copiada",
"claudeDesktopTitle": "Claude Desktop",
"claudeDesktopHint": "Configura claude_desktop_config.json automáticamente",
"addToClaudeDesktop": "Agregar a Claude Desktop",
"removeFromClaudeDesktop": "Eliminar de Claude Desktop",
"addedToClaudeDesktop": "Agregado a Claude Desktop. Reinicia Claude Desktop y activa la extensión en Configuración.",
"removedFromClaudeDesktop": "Eliminado de la configuración de Claude Desktop. Reinicia Claude Desktop.",
"claudeCodeTitle": "Claude Code",
"addToClaudeCode": "Agregar a Claude Code",
"removeFromClaudeCode": "Eliminar de Claude Code",
"addedToClaudeCode": "Agregado a Claude Code",
"removedFromClaudeCode": "Eliminado de Claude Code",
"config": "Configuración MCP",
"copyConfig": "Copiar Configuración"
"copyConfig": "Copiar Configuración",
"clientsLabel": "Clientes",
"connected": "Conectado",
"add": "Agregar",
"addedToClient": "Agregado a {{name}}",
"removedFromClient": "Eliminado de {{name}}",
"removeAriaLabel": "Eliminar de {{name}}",
"category": {
"desktopApp": "Aplicación de escritorio",
"cli": "CLI",
"editor": "Editor",
"editorExt": "Extensión de editor"
}
},
"tabApi": "API local",
"tabMcp": "MCP (asistentes IA)",
@@ -675,7 +695,9 @@
"mcpStarted": "Servidor MCP iniciado en puerto {{port}}",
"mcpStopped": "Servidor MCP detenido",
"mcpToggleFailed": "Error al alternar el servidor MCP",
"openSettings": "Abrir configuración de integraciones"
"openSettings": "Abrir configuración de integraciones",
"apiRunningOn": "Ejecutándose en",
"apiExampleRequest": "Solicitud de ejemplo"
},
"import": {
"title": "Importar Perfil",
@@ -1181,6 +1203,7 @@
"empty": "No se han subido extensiones aún.",
"noGroups": "No se han creado grupos de extensiones aún.",
"createGroup": "Crear Grupo",
"newGroup": "Nuevo grupo",
"addToGroup": "Agregar extensión...",
"removeFromGroup": "Eliminar del grupo",
"deleteGroup": "Eliminar grupo",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "Habilitar sincronización",
"syncDisableTooltip": "Deshabilitar sincronización",
"loadGroupsFailed": "Error al cargar grupos de extensiones",
"assignGroupFailed": "Error al asignar grupo de extensiones"
"assignGroupFailed": "Error al asignar grupo de extensiones",
"bulkDelete": {
"extensionsTitle": "Eliminar extensiones",
"extensionsDescription": "¿Eliminar {{count}} extensiones? {{names}}",
"groupsTitle": "Eliminar grupos de extensiones",
"groupsDescription": "¿Eliminar {{count}} grupos de extensiones? {{names}}",
"confirmButton": "Eliminar"
}
},
"pro": {
"badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "En espera de sincronización",
"errorWith": "Error de sincronización: {{error}}",
"error": "Error de sincronización",
"notSynced": "Sin sincronizar"
"notSynced": "Sin sincronizar",
"enable": "Activar sincronización",
"disable": "Desactivar sincronización",
"lockedInUse": "La sincronización está bloqueada mientras un perfil sincronizado lo use",
"bulkToggle": "Alternar sincronización"
},
"groupManagement": {
"description": "Administra tus grupos de perfiles",
@@ -1387,7 +1421,13 @@
"syncCannotDisable": "No se puede desactivar la sincronización mientras este grupo esté en uso por perfiles sincronizados",
"editGroupTooltip": "Editar grupo",
"deleteGroupTooltip": "Eliminar grupo",
"loadFailed": "Error al cargar los grupos"
"loadFailed": "Error al cargar los grupos",
"bulkDelete": {
"title": "Eliminar grupos",
"description": "¿Estás seguro de que quieres eliminar {{count}} grupos? {{names}}. Los perfiles se moverán a Predeterminado.",
"description_one": "¿Estás seguro de que quieres eliminar {{count}} grupo? {{names}}. Los perfiles se moverán a Predeterminado.",
"confirmButton": "Eliminar grupos"
}
},
"proxyAssignment": {
"title": "Asignar proxy / VPN",
@@ -1721,7 +1761,14 @@
"modes": {
"set": "Establecer",
"change": "Cambiar",
"remove": "Quitar"
"remove": "Quitar",
"validate": "Validar"
},
"verifyDialog": {
"title": "Validar contraseña del perfil",
"description": "Introduce la contraseña del perfil para confirmar que coincide con la almacenada en disco.",
"submit": "Validar",
"matchToast": "La contraseña es correcta"
}
},
"backendErrors": {
@@ -1749,7 +1796,6 @@
},
"rail": {
"profiles": "Perfiles",
"proxies": "Proxies",
"extensions": "Extensiones",
"groups": "Grupos",
"settings": "Ajustes",
@@ -1758,21 +1804,23 @@
"closeAriaLabel": "Cerrar menú",
"importProfile": "Importar perfil",
"importProfileHint": "Trae perfiles de otra herramienta",
"integrations": "Integraciones",
"integrationsHint": "Slack, MCP, automatizaciones",
"account": "Cuenta",
"accountHint": "Nube, facturación, sesión"
}
"keyboardShortcuts": "Atajos de teclado",
"keyboardShortcutsHint": "Ver todos los atajos"
},
"network": "Red",
"integrations": "Integraciones",
"account": "Cuenta"
},
"pageTitle": {
"proxies": "Proxies",
"proxies": "Red",
"extensions": "Extensiones",
"groups": "Grupos",
"vpns": "VPN",
"vpns": "Red",
"settings": "Ajustes",
"integrations": "Integraciones",
"account": "Cuenta",
"import": "Importar perfil"
"import": "Importar perfil",
"shortcuts": "Atajos de teclado"
},
"encryption": {
"required": {
@@ -1825,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"
}
}
+113 -34
View File
@@ -32,7 +32,8 @@
"downloading": "Téléchargement...",
"minimize": "Réduire",
"saving": "Enregistrement…",
"saved": "Enregistré"
"saved": "Enregistré",
"copied": "Copié"
},
"status": {
"active": "Actif",
@@ -158,7 +159,15 @@
"passwordSaved": "Mot de passe de chiffrement défini",
"passwordMismatch": "Les mots de passe ne correspondent pas",
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères",
"requiresProOrOwner": "Le chiffrement des profils est disponible pour les utilisateurs Pro et les propriétaires d'équipe."
"requiresProOrOwner": "Le chiffrement des profils est disponible pour les utilisateurs Pro et les propriétaires d'équipe.",
"validatePassword": "Valider",
"validateDialog": {
"title": "Valider le mot de passe de chiffrement",
"description": "Saisissez votre mot de passe de chiffrement pour vérifier qu'il correspond à celui enregistré sur cet appareil.",
"submit": "Valider",
"matchToast": "Le mot de passe est correct",
"mismatchToast": "Le mot de passe ne correspond pas"
}
},
"commercial": {
"title": "Licence commerciale",
@@ -376,6 +385,9 @@
"deleteFailed": "Échec de la suppression du proxy",
"deleteTitle": "Supprimer le proxy",
"deleteDescription": "Cette action est irréversible. Le proxy « {{name}} » sera supprimé définitivement.",
"newProxy": "Nouveau proxy",
"newVpn": "Nouveau VPN",
"protocolCol": "Protocole",
"title": "Proxys et VPN"
},
"add": "Ajouter un proxy",
@@ -480,6 +492,13 @@
"continueButton": "Continuer",
"doneButton": "Terminé",
"failed": "Échec de l'import des proxys"
},
"bulkDelete": {
"proxiesTitle": "Supprimer les proxys sélectionnés",
"proxiesDescription": "Cette action est irréversible. {{count}} proxy(s) seront définitivement supprimés : {{names}}.",
"vpnsTitle": "Supprimer les VPN sélectionnés",
"vpnsDescription": "Cette action est irréversible. {{count}} VPN(s) seront définitivement supprimés : {{names}}.",
"confirmButton": "Supprimer {{count}}"
}
},
"groups": {
@@ -488,10 +507,8 @@
"add": "Ajouter un groupe",
"edit": "Modifier le groupe",
"delete": "Supprimer le groupe",
"defaultGroup": "Par défaut",
"defaultGroupNoGroup": "Par défaut (Aucun groupe)",
"moveToDefault": "Déplacer les profils vers le groupe Par défaut",
"noGroupDescription": "Les profils sans groupe apparaîtront dans le groupe « Par défaut ».",
"moveToDefault": "Retirer les profils du groupe",
"noGroupDescription": "Les profils sans groupe apparaissent dans le filtre « Tous ».",
"assignSuccess": "{{count}} profil(s) assigné(s) à {{group}} avec succès",
"noGroups": "Aucun groupe créé",
"noGroupsDescription": "Créez un groupe pour organiser vos profils.",
@@ -521,7 +538,6 @@
"loadingProfiles": "Chargement des profils associés...",
"associatedProfiles": "Profils Associés ({{count}})",
"whatToDoWithProfiles": "Que faire de ces profils ?",
"moveToDefaultOption": "Déplacer les profils vers le groupe Par défaut",
"deleteAlongWithGroup": "Supprimer les profils avec le groupe",
"noAssociatedProfiles": "Ce groupe n'a pas de profils associés.",
"deleteGroup": "Supprimer le Groupe",
@@ -530,7 +546,10 @@
"unknownGroup": "Groupe inconnu",
"profileGroupsAriaLabel": "Groupes de profils",
"loading": "Chargement des groupes...",
"all": "Tous"
"all": "Tous",
"noGroup": "Aucun groupe",
"pageTitle": "Groupes de profils",
"pageDescription": "Les groupes de profils vous permettent d'organiser les navigateurs par client, environnement ou cas d'usage. Synchronisez les groupes entre appareils pour les partager."
},
"sync": {
"mode": {
@@ -637,19 +656,20 @@
"tokenCopied": "Jeton copié",
"url": "URL du serveur MCP",
"urlCopied": "URL copiée",
"claudeDesktopTitle": "Claude Desktop",
"claudeDesktopHint": "Configure claude_desktop_config.json automatiquement",
"addToClaudeDesktop": "Ajouter à Claude Desktop",
"removeFromClaudeDesktop": "Supprimer de Claude Desktop",
"addedToClaudeDesktop": "Ajouté à Claude Desktop. Redémarrez Claude Desktop et activez l'extension dans les Paramètres.",
"removedFromClaudeDesktop": "Supprimé de la configuration de Claude Desktop. Veuillez redémarrer Claude Desktop.",
"claudeCodeTitle": "Claude Code",
"addToClaudeCode": "Ajouter à Claude Code",
"removeFromClaudeCode": "Supprimer de Claude Code",
"addedToClaudeCode": "Ajouté à Claude Code",
"removedFromClaudeCode": "Supprimé de Claude Code",
"config": "Configuration MCP",
"copyConfig": "Copier la configuration"
"copyConfig": "Copier la configuration",
"clientsLabel": "Clients",
"connected": "Connecté",
"add": "Ajouter",
"addedToClient": "Ajouté à {{name}}",
"removedFromClient": "Supprimé de {{name}}",
"removeAriaLabel": "Supprimer de {{name}}",
"category": {
"desktopApp": "Application bureau",
"cli": "CLI",
"editor": "Éditeur",
"editorExt": "Ext. d'éditeur"
}
},
"tabApi": "API locale",
"tabMcp": "MCP (Assistants IA)",
@@ -675,7 +695,9 @@
"mcpStarted": "Serveur MCP démarré sur le port {{port}}",
"mcpStopped": "Serveur MCP arrêté",
"mcpToggleFailed": "Échec du basculement du serveur MCP",
"openSettings": "Ouvrir les paramètres d'intégrations"
"openSettings": "Ouvrir les paramètres d'intégrations",
"apiRunningOn": "En cours sur",
"apiExampleRequest": "Exemple de requête"
},
"import": {
"title": "Importer un profil",
@@ -1181,6 +1203,7 @@
"empty": "Aucune extension téléchargée pour l'instant.",
"noGroups": "Aucun groupe d'extensions créé pour l'instant.",
"createGroup": "Créer un Groupe",
"newGroup": "Nouveau groupe",
"addToGroup": "Ajouter une extension...",
"removeFromGroup": "Retirer du groupe",
"deleteGroup": "Supprimer le groupe",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "Activer la synchronisation",
"syncDisableTooltip": "Désactiver la synchronisation",
"loadGroupsFailed": "Échec du chargement des groupes d'extensions",
"assignGroupFailed": "Échec de l'attribution du groupe d'extensions"
"assignGroupFailed": "Échec de l'attribution du groupe d'extensions",
"bulkDelete": {
"extensionsTitle": "Supprimer les extensions",
"extensionsDescription": "Supprimer {{count}} extensions ? {{names}}",
"groupsTitle": "Supprimer les groupes d'extensions",
"groupsDescription": "Supprimer {{count}} groupes d'extensions ? {{names}}",
"confirmButton": "Supprimer"
}
},
"pro": {
"badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "En attente de synchronisation",
"errorWith": "Erreur de synchronisation : {{error}}",
"error": "Erreur de synchronisation",
"notSynced": "Non synchronisé"
"notSynced": "Non synchronisé",
"enable": "Activer la synchronisation",
"disable": "Désactiver la synchronisation",
"lockedInUse": "La synchronisation est verrouillée tant qu'un profil synchronisé l'utilise",
"bulkToggle": "Basculer la synchronisation"
},
"groupManagement": {
"description": "Gérez vos groupes de profils",
@@ -1387,7 +1421,13 @@
"syncCannotDisable": "La sync ne peut pas être désactivée tant que ce groupe est utilisé par des profils synchronisés",
"editGroupTooltip": "Modifier le groupe",
"deleteGroupTooltip": "Supprimer le groupe",
"loadFailed": "Échec du chargement des groupes"
"loadFailed": "Échec du chargement des groupes",
"bulkDelete": {
"title": "Supprimer les groupes",
"description": "Êtes-vous sûr de vouloir supprimer {{count}} groupes ? {{names}}. Les profils seront déplacés vers Par défaut.",
"description_one": "Êtes-vous sûr de vouloir supprimer {{count}} groupe ? {{names}}. Les profils seront déplacés vers Par défaut.",
"confirmButton": "Supprimer les groupes"
}
},
"proxyAssignment": {
"title": "Assigner un proxy / VPN",
@@ -1721,7 +1761,14 @@
"modes": {
"set": "Définir",
"change": "Modifier",
"remove": "Supprimer"
"remove": "Supprimer",
"validate": "Valider"
},
"verifyDialog": {
"title": "Valider le mot de passe du profil",
"description": "Saisissez le mot de passe du profil pour vérifier qu'il correspond à celui enregistré sur le disque.",
"submit": "Valider",
"matchToast": "Le mot de passe est correct"
}
},
"backendErrors": {
@@ -1749,7 +1796,6 @@
},
"rail": {
"profiles": "Profils",
"proxies": "Proxys",
"extensions": "Extensions",
"groups": "Groupes",
"settings": "Paramètres",
@@ -1758,21 +1804,23 @@
"closeAriaLabel": "Fermer le menu",
"importProfile": "Importer un profil",
"importProfileHint": "Importer depuis un autre outil",
"integrations": "Intégrations",
"integrationsHint": "Slack, MCP, automatisations",
"account": "Compte",
"accountHint": "Cloud, facturation, connexion"
}
"keyboardShortcuts": "Raccourcis clavier",
"keyboardShortcutsHint": "Voir tous les raccourcis"
},
"network": "Réseau",
"integrations": "Intégrations",
"account": "Compte"
},
"pageTitle": {
"proxies": "Proxys",
"proxies": "Réseau",
"extensions": "Extensions",
"groups": "Groupes",
"vpns": "VPN",
"vpns": "Réseau",
"settings": "Paramètres",
"integrations": "Intégrations",
"account": "Compte",
"import": "Importer un profil"
"import": "Importer un profil",
"shortcuts": "Raccourcis clavier"
},
"encryption": {
"required": {
@@ -1825,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"
}
}
+113 -34
View File
@@ -32,7 +32,8 @@
"downloading": "ダウンロード中...",
"minimize": "最小化",
"saving": "保存中…",
"saved": "保存しました"
"saved": "保存しました",
"copied": "コピーしました"
},
"status": {
"active": "アクティブ",
@@ -158,7 +159,15 @@
"passwordSaved": "暗号化パスワードが設定されました",
"passwordMismatch": "パスワードが一致しません",
"passwordTooShort": "パスワードは8文字以上である必要があります",
"requiresProOrOwner": "プロファイルの暗号化はProユーザーとチームオーナーのみ利用できます。"
"requiresProOrOwner": "プロファイルの暗号化はProユーザーとチームオーナーのみ利用できます。",
"validatePassword": "確認",
"validateDialog": {
"title": "暗号化パスワードを確認",
"description": "このデバイスに保存されているパスワードと一致するか、暗号化パスワードを入力してください。",
"submit": "確認",
"matchToast": "パスワードが一致しました",
"mismatchToast": "パスワードが一致しません"
}
},
"commercial": {
"title": "商用ライセンス",
@@ -376,6 +385,9 @@
"deleteFailed": "プロキシの削除に失敗しました",
"deleteTitle": "プロキシを削除",
"deleteDescription": "この操作は取り消せません。プロキシ「{{name}}」は完全に削除されます。",
"newProxy": "新しいプロキシ",
"newVpn": "新しいVPN",
"protocolCol": "プロトコル",
"title": "プロキシと VPN"
},
"add": "プロキシを追加",
@@ -480,6 +492,13 @@
"continueButton": "続ける",
"doneButton": "完了",
"failed": "プロキシのインポートに失敗しました"
},
"bulkDelete": {
"proxiesTitle": "選択したプロキシを削除",
"proxiesDescription": "この操作は取り消せません。{{count}} 件のプロキシを完全に削除します: {{names}}",
"vpnsTitle": "選択したVPNを削除",
"vpnsDescription": "この操作は取り消せません。{{count}} 件のVPNを完全に削除します: {{names}}",
"confirmButton": "{{count}} 件を削除"
}
},
"groups": {
@@ -488,10 +507,8 @@
"add": "グループを追加",
"edit": "グループを編集",
"delete": "グループを削除",
"defaultGroup": "デフォルト",
"defaultGroupNoGroup": "デフォルト(グループなし)",
"moveToDefault": "プロファイルをデフォルトグループに移動",
"noGroupDescription": "グループに属していないプロファイルは「デフォルト」グループに表示されます。",
"moveToDefault": "プロファイルをグループから外す",
"noGroupDescription": "グループに属さないプロファイルは「すべて」フィルターに表示されます。",
"assignSuccess": "{{count}} 件のプロファイルを {{group}} に割り当てました",
"noGroups": "グループがありません",
"noGroupsDescription": "プロファイルを整理するためのグループを作成してください。",
@@ -521,7 +538,6 @@
"loadingProfiles": "関連するプロファイルを読み込んでいます...",
"associatedProfiles": "関連プロファイル ({{count}})",
"whatToDoWithProfiles": "これらのプロファイルをどうしますか?",
"moveToDefaultOption": "プロファイルをデフォルトグループに移動",
"deleteAlongWithGroup": "プロファイルもグループと一緒に削除",
"noAssociatedProfiles": "このグループには関連するプロファイルがありません。",
"deleteGroup": "グループを削除",
@@ -530,7 +546,10 @@
"unknownGroup": "不明なグループ",
"profileGroupsAriaLabel": "プロファイルグループ",
"loading": "グループを読み込み中...",
"all": "すべて"
"all": "すべて",
"noGroup": "グループなし",
"pageTitle": "プロファイルグループ",
"pageDescription": "プロファイルグループを使うと、クライアント、環境、用途ごとにブラウザを整理できます。デバイス間でグループを同期して共有しましょう。"
},
"sync": {
"mode": {
@@ -637,19 +656,20 @@
"tokenCopied": "トークンをコピーしました",
"url": "MCPサーバーURL",
"urlCopied": "URLをコピーしました",
"claudeDesktopTitle": "Claude Desktop",
"claudeDesktopHint": "claude_desktop_config.json を自動的に設定します",
"addToClaudeDesktop": "Claude Desktop に追加",
"removeFromClaudeDesktop": "Claude Desktop から削除",
"addedToClaudeDesktop": "Claude Desktop に追加しました。Claude Desktop を再起動し、設定で拡張機能を有効にしてください。",
"removedFromClaudeDesktop": "Claude Desktop の設定から削除しました。Claude Desktop を再起動してください。",
"claudeCodeTitle": "Claude Code",
"addToClaudeCode": "Claude Code に追加",
"removeFromClaudeCode": "Claude Code から削除",
"addedToClaudeCode": "Claude Code に追加しました",
"removedFromClaudeCode": "Claude Code から削除しました",
"config": "MCP設定",
"copyConfig": "設定をコピー"
"copyConfig": "設定をコピー",
"clientsLabel": "クライアント",
"connected": "接続済み",
"add": "追加",
"addedToClient": "{{name}} に追加しました",
"removedFromClient": "{{name}} から削除しました",
"removeAriaLabel": "{{name}} から削除",
"category": {
"desktopApp": "デスクトップアプリ",
"cli": "CLI",
"editor": "エディタ",
"editorExt": "エディタ拡張"
}
},
"tabApi": "ローカル API",
"tabMcp": "MCP (AI アシスタント)",
@@ -675,7 +695,9 @@
"mcpStarted": "MCP サーバーをポート {{port}} で起動しました",
"mcpStopped": "MCP サーバーを停止しました",
"mcpToggleFailed": "MCP サーバーの切り替えに失敗しました",
"openSettings": "統合設定を開く"
"openSettings": "統合設定を開く",
"apiRunningOn": "実行中",
"apiExampleRequest": "リクエスト例"
},
"import": {
"title": "プロファイルをインポート",
@@ -1181,6 +1203,7 @@
"empty": "まだ拡張機能がアップロードされていません。",
"noGroups": "まだ拡張機能グループが作成されていません。",
"createGroup": "グループを作成",
"newGroup": "新しいグループ",
"addToGroup": "拡張機能を追加...",
"removeFromGroup": "グループから削除",
"deleteGroup": "グループを削除",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "同期を有効にする",
"syncDisableTooltip": "同期を無効にする",
"loadGroupsFailed": "拡張機能グループの読み込みに失敗しました",
"assignGroupFailed": "拡張機能グループの割り当てに失敗しました"
"assignGroupFailed": "拡張機能グループの割り当てに失敗しました",
"bulkDelete": {
"extensionsTitle": "拡張機能を削除",
"extensionsDescription": "{{count}}件の拡張機能を削除しますか? {{names}}",
"groupsTitle": "拡張機能グループを削除",
"groupsDescription": "{{count}}件の拡張機能グループを削除しますか? {{names}}",
"confirmButton": "削除"
}
},
"pro": {
"badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "同期待ち",
"errorWith": "同期エラー: {{error}}",
"error": "同期エラー",
"notSynced": "未同期"
"notSynced": "未同期",
"enable": "同期を有効化",
"disable": "同期を無効化",
"lockedInUse": "同期されたプロファイルが使用中のため、同期は無効化できません",
"bulkToggle": "同期を切り替え"
},
"groupManagement": {
"description": "プロファイルのグループを管理します",
@@ -1387,7 +1421,13 @@
"syncCannotDisable": "このグループが同期されたプロファイルで使用されている間は同期を無効にできません",
"editGroupTooltip": "グループを編集",
"deleteGroupTooltip": "グループを削除",
"loadFailed": "グループの読み込みに失敗しました"
"loadFailed": "グループの読み込みに失敗しました",
"bulkDelete": {
"title": "グループを削除",
"description": "{{count}} 個のグループを削除してもよろしいですか?{{names}}。プロファイルはデフォルトに移動されます。",
"description_one": "{{count}} 個のグループを削除してもよろしいですか?{{names}}。プロファイルはデフォルトに移動されます。",
"confirmButton": "グループを削除"
}
},
"proxyAssignment": {
"title": "プロキシ / VPN を割り当てる",
@@ -1721,7 +1761,14 @@
"modes": {
"set": "設定",
"change": "変更",
"remove": "削除"
"remove": "削除",
"validate": "確認"
},
"verifyDialog": {
"title": "プロファイルパスワードを確認",
"description": "ディスクに保存されているプロファイルパスワードと一致するか入力してください。",
"submit": "確認",
"matchToast": "パスワードが一致しました"
}
},
"backendErrors": {
@@ -1749,7 +1796,6 @@
},
"rail": {
"profiles": "プロファイル",
"proxies": "プロキシ",
"extensions": "拡張機能",
"groups": "グループ",
"settings": "設定",
@@ -1758,21 +1804,23 @@
"closeAriaLabel": "メニューを閉じる",
"importProfile": "プロファイルをインポート",
"importProfileHint": "別のツールから取り込む",
"integrations": "連携",
"integrationsHint": "Slack、MCP、自動化",
"account": "アカウント",
"accountHint": "クラウド、請求、サインイン"
}
"keyboardShortcuts": "キーボードショートカット",
"keyboardShortcutsHint": "すべてのショートカットを表示"
},
"network": "ネットワーク",
"integrations": "連携",
"account": "アカウント"
},
"pageTitle": {
"proxies": "プロキシ",
"proxies": "ネットワーク",
"extensions": "拡張機能",
"groups": "グループ",
"vpns": "VPN",
"vpns": "ネットワーク",
"settings": "設定",
"integrations": "連携",
"account": "アカウント",
"import": "プロファイルをインポート"
"import": "プロファイルをインポート",
"shortcuts": "キーボードショートカット"
},
"encryption": {
"required": {
@@ -1825,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": "設定へ移動"
}
}
+113 -34
View File
@@ -32,7 +32,8 @@
"downloading": "Baixando...",
"minimize": "Minimizar",
"saving": "Salvando…",
"saved": "Salvo"
"saved": "Salvo",
"copied": "Copiado"
},
"status": {
"active": "Ativo",
@@ -158,7 +159,15 @@
"passwordSaved": "Senha de criptografia definida",
"passwordMismatch": "As senhas não coincidem",
"passwordTooShort": "A senha deve ter pelo menos 8 caracteres",
"requiresProOrOwner": "A criptografia de perfis está disponível para usuários Pro e proprietários de equipe."
"requiresProOrOwner": "A criptografia de perfis está disponível para usuários Pro e proprietários de equipe.",
"validatePassword": "Validar",
"validateDialog": {
"title": "Validar senha de criptografia",
"description": "Digite sua senha de criptografia para verificar se corresponde à armazenada neste dispositivo.",
"submit": "Validar",
"matchToast": "A senha está correta",
"mismatchToast": "A senha não corresponde"
}
},
"commercial": {
"title": "Licença Comercial",
@@ -376,6 +385,9 @@
"deleteFailed": "Falha ao excluir proxy",
"deleteTitle": "Excluir proxy",
"deleteDescription": "Esta ação não pode ser desfeita. O proxy \"{{name}}\" será excluído permanentemente.",
"newProxy": "Novo proxy",
"newVpn": "Nova VPN",
"protocolCol": "Protocolo",
"title": "Proxies e VPNs"
},
"add": "Adicionar Proxy",
@@ -480,6 +492,13 @@
"continueButton": "Continuar",
"doneButton": "Concluído",
"failed": "Falha ao importar proxies"
},
"bulkDelete": {
"proxiesTitle": "Excluir proxies selecionados",
"proxiesDescription": "Esta ação não pode ser desfeita. Isso excluirá permanentemente {{count}} proxy(s): {{names}}.",
"vpnsTitle": "Excluir VPNs selecionadas",
"vpnsDescription": "Esta ação não pode ser desfeita. Isso excluirá permanentemente {{count}} VPN(s): {{names}}.",
"confirmButton": "Excluir {{count}}"
}
},
"groups": {
@@ -488,10 +507,8 @@
"add": "Adicionar Grupo",
"edit": "Editar Grupo",
"delete": "Excluir Grupo",
"defaultGroup": "Padrão",
"defaultGroupNoGroup": "Padrão (Sem Grupo)",
"moveToDefault": "Mover perfis para o grupo Padrão",
"noGroupDescription": "Perfis sem grupo aparecerão no grupo \"Padrão\".",
"moveToDefault": "Remover perfis do grupo",
"noGroupDescription": "Perfis sem grupo aparecem no filtro \"Todos\".",
"assignSuccess": "{{count}} perfil(s) atribuído(s) a {{group}} com sucesso",
"noGroups": "Nenhum grupo criado",
"noGroupsDescription": "Crie um grupo para organizar seus perfis.",
@@ -521,7 +538,6 @@
"loadingProfiles": "Carregando perfis associados...",
"associatedProfiles": "Perfis Associados ({{count}})",
"whatToDoWithProfiles": "O que fazer com esses perfis?",
"moveToDefaultOption": "Mover perfis para o grupo Padrão",
"deleteAlongWithGroup": "Excluir perfis junto com o grupo",
"noAssociatedProfiles": "Este grupo não tem perfis associados.",
"deleteGroup": "Excluir Grupo",
@@ -530,7 +546,10 @@
"unknownGroup": "Grupo desconhecido",
"profileGroupsAriaLabel": "Grupos de perfis",
"loading": "Carregando grupos...",
"all": "Todos"
"all": "Todos",
"noGroup": "Sem grupo",
"pageTitle": "Grupos de perfis",
"pageDescription": "Os grupos de perfis permitem organizar os navegadores por cliente, ambiente ou caso de uso. Sincronize grupos entre dispositivos para compartilhá-los."
},
"sync": {
"mode": {
@@ -637,19 +656,20 @@
"tokenCopied": "Token copiado",
"url": "URL do servidor MCP",
"urlCopied": "URL copiada",
"claudeDesktopTitle": "Claude Desktop",
"claudeDesktopHint": "Configura claude_desktop_config.json automaticamente",
"addToClaudeDesktop": "Adicionar ao Claude Desktop",
"removeFromClaudeDesktop": "Remover do Claude Desktop",
"addedToClaudeDesktop": "Adicionado ao Claude Desktop. Reinicie o Claude Desktop e ative a extensão em Configurações.",
"removedFromClaudeDesktop": "Removido da configuração do Claude Desktop. Reinicie o Claude Desktop.",
"claudeCodeTitle": "Claude Code",
"addToClaudeCode": "Adicionar ao Claude Code",
"removeFromClaudeCode": "Remover do Claude Code",
"addedToClaudeCode": "Adicionado ao Claude Code",
"removedFromClaudeCode": "Removido do Claude Code",
"config": "Configuração MCP",
"copyConfig": "Copiar Configuração"
"copyConfig": "Copiar Configuração",
"clientsLabel": "Clientes",
"connected": "Conectado",
"add": "Adicionar",
"addedToClient": "Adicionado a {{name}}",
"removedFromClient": "Removido de {{name}}",
"removeAriaLabel": "Remover de {{name}}",
"category": {
"desktopApp": "Aplicativo de desktop",
"cli": "CLI",
"editor": "Editor",
"editorExt": "Extensão de editor"
}
},
"tabApi": "API local",
"tabMcp": "MCP (Assistentes de IA)",
@@ -675,7 +695,9 @@
"mcpStarted": "Servidor MCP iniciado na porta {{port}}",
"mcpStopped": "Servidor MCP parado",
"mcpToggleFailed": "Falha ao alternar o servidor MCP",
"openSettings": "Abrir configurações de integrações"
"openSettings": "Abrir configurações de integrações",
"apiRunningOn": "Em execução em",
"apiExampleRequest": "Exemplo de solicitação"
},
"import": {
"title": "Importar Perfil",
@@ -1181,6 +1203,7 @@
"empty": "Nenhuma extensão enviada ainda.",
"noGroups": "Nenhum grupo de extensões criado ainda.",
"createGroup": "Criar Grupo",
"newGroup": "Novo grupo",
"addToGroup": "Adicionar extensão...",
"removeFromGroup": "Remover do grupo",
"deleteGroup": "Excluir grupo",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "Ativar sincronização",
"syncDisableTooltip": "Desativar sincronização",
"loadGroupsFailed": "Falha ao carregar grupos de extensões",
"assignGroupFailed": "Falha ao atribuir grupo de extensões"
"assignGroupFailed": "Falha ao atribuir grupo de extensões",
"bulkDelete": {
"extensionsTitle": "Excluir extensões",
"extensionsDescription": "Excluir {{count}} extensões? {{names}}",
"groupsTitle": "Excluir grupos de extensões",
"groupsDescription": "Excluir {{count}} grupos de extensões? {{names}}",
"confirmButton": "Excluir"
}
},
"pro": {
"badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "Aguardando sincronização",
"errorWith": "Erro de sincronização: {{error}}",
"error": "Erro de sincronização",
"notSynced": "Não sincronizado"
"notSynced": "Não sincronizado",
"enable": "Ativar sincronização",
"disable": "Desativar sincronização",
"lockedInUse": "A sincronização está bloqueada enquanto um perfil sincronizado a usar",
"bulkToggle": "Alternar sincronização"
},
"groupManagement": {
"description": "Gerencie seus grupos de perfis",
@@ -1387,7 +1421,13 @@
"syncCannotDisable": "A sincronização não pode ser desativada enquanto este grupo estiver em uso por perfis sincronizados",
"editGroupTooltip": "Editar grupo",
"deleteGroupTooltip": "Excluir grupo",
"loadFailed": "Falha ao carregar grupos"
"loadFailed": "Falha ao carregar grupos",
"bulkDelete": {
"title": "Excluir grupos",
"description": "Tem certeza que deseja excluir {{count}} grupos? {{names}}. Os perfis serão movidos para Padrão.",
"description_one": "Tem certeza que deseja excluir {{count}} grupo? {{names}}. Os perfis serão movidos para Padrão.",
"confirmButton": "Excluir grupos"
}
},
"proxyAssignment": {
"title": "Atribuir proxy / VPN",
@@ -1721,7 +1761,14 @@
"modes": {
"set": "Definir",
"change": "Alterar",
"remove": "Remover"
"remove": "Remover",
"validate": "Validar"
},
"verifyDialog": {
"title": "Validar senha do perfil",
"description": "Digite a senha do perfil para confirmar se corresponde à armazenada em disco.",
"submit": "Validar",
"matchToast": "A senha está correta"
}
},
"backendErrors": {
@@ -1749,7 +1796,6 @@
},
"rail": {
"profiles": "Perfis",
"proxies": "Proxies",
"extensions": "Extensões",
"groups": "Grupos",
"settings": "Configurações",
@@ -1758,21 +1804,23 @@
"closeAriaLabel": "Fechar menu",
"importProfile": "Importar perfil",
"importProfileHint": "Trazer perfis de outra ferramenta",
"integrations": "Integrações",
"integrationsHint": "Slack, MCP, automações",
"account": "Conta",
"accountHint": "Nuvem, cobrança, login"
}
"keyboardShortcuts": "Atalhos de teclado",
"keyboardShortcutsHint": "Ver todos os atalhos"
},
"network": "Rede",
"integrations": "Integrações",
"account": "Conta"
},
"pageTitle": {
"proxies": "Proxies",
"proxies": "Rede",
"extensions": "Extensões",
"groups": "Grupos",
"vpns": "VPN",
"vpns": "Rede",
"settings": "Configurações",
"integrations": "Integrações",
"account": "Conta",
"import": "Importar perfil"
"import": "Importar perfil",
"shortcuts": "Atalhos de teclado"
},
"encryption": {
"required": {
@@ -1825,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"
}
}
+113 -34
View File
@@ -32,7 +32,8 @@
"downloading": "Загрузка...",
"minimize": "Свернуть",
"saving": "Сохраняем…",
"saved": "Сохранено"
"saved": "Сохранено",
"copied": "Скопировано"
},
"status": {
"active": "Активен",
@@ -158,7 +159,15 @@
"passwordSaved": "Пароль шифрования установлен",
"passwordMismatch": "Пароли не совпадают",
"passwordTooShort": "Пароль должен содержать не менее 8 символов",
"requiresProOrOwner": "Шифрование профилей доступно для пользователей Pro и владельцев команд."
"requiresProOrOwner": "Шифрование профилей доступно для пользователей Pro и владельцев команд.",
"validatePassword": "Проверить",
"validateDialog": {
"title": "Проверка пароля шифрования",
"description": "Введите пароль шифрования, чтобы убедиться, что он совпадает с сохранённым на этом устройстве.",
"submit": "Проверить",
"matchToast": "Пароль верен",
"mismatchToast": "Пароль не совпадает"
}
},
"commercial": {
"title": "Коммерческая лицензия",
@@ -376,6 +385,9 @@
"deleteFailed": "Не удалось удалить прокси",
"deleteTitle": "Удалить прокси",
"deleteDescription": "Это действие нельзя отменить. Прокси «{{name}}» будет удален навсегда.",
"newProxy": "Новый прокси",
"newVpn": "Новый VPN",
"protocolCol": "Протокол",
"title": "Прокси и VPN"
},
"add": "Добавить прокси",
@@ -480,6 +492,13 @@
"continueButton": "Продолжить",
"doneButton": "Готово",
"failed": "Не удалось импортировать прокси"
},
"bulkDelete": {
"proxiesTitle": "Удалить выбранные прокси",
"proxiesDescription": "Это действие нельзя отменить. Будет безвозвратно удалено прокси: {{count}} — {{names}}.",
"vpnsTitle": "Удалить выбранные VPN",
"vpnsDescription": "Это действие нельзя отменить. Будет безвозвратно удалено VPN: {{count}} — {{names}}.",
"confirmButton": "Удалить {{count}}"
}
},
"groups": {
@@ -488,10 +507,8 @@
"add": "Добавить группу",
"edit": "Редактировать группу",
"delete": "Удалить группу",
"defaultGroup": "По умолчанию",
"defaultGroupNoGroup": "По умолчанию (Без группы)",
"moveToDefault": "Переместить профили в группу по умолчанию",
"noGroupDescription": "Профили без группы будут отображаться в группе «По умолчанию».",
"moveToDefault": "Убрать профили из группы",
"noGroupDescription": "Профили без группы отображаются в фильтре «Все».",
"assignSuccess": "Успешно назначено {{count}} профиль(ей) в {{group}}",
"noGroups": "Группы не созданы",
"noGroupsDescription": "Создайте группу для организации профилей.",
@@ -521,7 +538,6 @@
"loadingProfiles": "Загрузка связанных профилей...",
"associatedProfiles": "Связанные профили ({{count}})",
"whatToDoWithProfiles": "Что сделать с этими профилями?",
"moveToDefaultOption": "Переместить профили в группу По умолчанию",
"deleteAlongWithGroup": "Удалить профили вместе с группой",
"noAssociatedProfiles": "У этой группы нет связанных профилей.",
"deleteGroup": "Удалить группу",
@@ -530,7 +546,10 @@
"unknownGroup": "Неизвестная группа",
"profileGroupsAriaLabel": "Группы профилей",
"loading": "Загрузка групп...",
"all": "Все"
"all": "Все",
"noGroup": "Без группы",
"pageTitle": "Группы профилей",
"pageDescription": "Группы профилей позволяют организовать браузеры по клиенту, окружению или сценарию использования. Синхронизируйте группы между устройствами, чтобы делиться ими."
},
"sync": {
"mode": {
@@ -637,19 +656,20 @@
"tokenCopied": "Токен скопирован",
"url": "URL MCP сервера",
"urlCopied": "URL скопирован",
"claudeDesktopTitle": "Claude Desktop",
"claudeDesktopHint": "Автоматически настраивает claude_desktop_config.json",
"addToClaudeDesktop": "Добавить в Claude Desktop",
"removeFromClaudeDesktop": "Удалить из Claude Desktop",
"addedToClaudeDesktop": "Добавлено в Claude Desktop. Перезапустите Claude Desktop и включите расширение в Настройках.",
"removedFromClaudeDesktop": "Удалено из конфигурации Claude Desktop. Перезапустите Claude Desktop.",
"claudeCodeTitle": "Claude Code",
"addToClaudeCode": "Добавить в Claude Code",
"removeFromClaudeCode": "Удалить из Claude Code",
"addedToClaudeCode": "Добавлено в Claude Code",
"removedFromClaudeCode": "Удалено из Claude Code",
"config": "Конфигурация MCP",
"copyConfig": "Копировать конфигурацию"
"copyConfig": "Копировать конфигурацию",
"clientsLabel": "Клиенты",
"connected": "Подключено",
"add": "Добавить",
"addedToClient": "Добавлено в {{name}}",
"removedFromClient": "Удалено из {{name}}",
"removeAriaLabel": "Удалить из {{name}}",
"category": {
"desktopApp": "Десктоп-приложение",
"cli": "CLI",
"editor": "Редактор",
"editorExt": "Расширение редактора"
}
},
"tabApi": "Локальный API",
"tabMcp": "MCP (ИИ-ассистенты)",
@@ -675,7 +695,9 @@
"mcpStarted": "MCP сервер запущен на порту {{port}}",
"mcpStopped": "MCP сервер остановлен",
"mcpToggleFailed": "Не удалось переключить MCP сервер",
"openSettings": "Открыть настройки интеграций"
"openSettings": "Открыть настройки интеграций",
"apiRunningOn": "Запущен на",
"apiExampleRequest": "Пример запроса"
},
"import": {
"title": "Импорт профиля",
@@ -1181,6 +1203,7 @@
"empty": "Расширения ещё не загружены.",
"noGroups": "Группы расширений ещё не созданы.",
"createGroup": "Создать группу",
"newGroup": "Новая группа",
"addToGroup": "Добавить расширение...",
"removeFromGroup": "Удалить из группы",
"deleteGroup": "Удалить группу",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "Включить синхронизацию",
"syncDisableTooltip": "Отключить синхронизацию",
"loadGroupsFailed": "Не удалось загрузить группы расширений",
"assignGroupFailed": "Не удалось назначить группу расширений"
"assignGroupFailed": "Не удалось назначить группу расширений",
"bulkDelete": {
"extensionsTitle": "Удалить расширения",
"extensionsDescription": "Удалить {{count}} расширений? {{names}}",
"groupsTitle": "Удалить группы расширений",
"groupsDescription": "Удалить {{count}} групп расширений? {{names}}",
"confirmButton": "Удалить"
}
},
"pro": {
"badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "Ожидание синхронизации",
"errorWith": "Ошибка синхронизации: {{error}}",
"error": "Ошибка синхронизации",
"notSynced": "Не синхронизировано"
"notSynced": "Не синхронизировано",
"enable": "Включить синхронизацию",
"disable": "Отключить синхронизацию",
"lockedInUse": "Синхронизация заблокирована, пока используется синхронизируемым профилем",
"bulkToggle": "Переключить синхронизацию"
},
"groupManagement": {
"description": "Управляйте группами профилей",
@@ -1387,7 +1421,13 @@
"syncCannotDisable": "Нельзя отключить синхронизацию, пока эта группа используется синхронизированными профилями",
"editGroupTooltip": "Редактировать группу",
"deleteGroupTooltip": "Удалить группу",
"loadFailed": "Не удалось загрузить группы"
"loadFailed": "Не удалось загрузить группы",
"bulkDelete": {
"title": "Удалить группы",
"description": "Вы уверены, что хотите удалить {{count}} групп? {{names}}. Профили будут перемещены в группу по умолчанию.",
"description_one": "Вы уверены, что хотите удалить {{count}} группу? {{names}}. Профили будут перемещены в группу по умолчанию.",
"confirmButton": "Удалить группы"
}
},
"proxyAssignment": {
"title": "Назначить прокси / VPN",
@@ -1721,7 +1761,14 @@
"modes": {
"set": "Задать",
"change": "Изменить",
"remove": "Удалить"
"remove": "Удалить",
"validate": "Проверить"
},
"verifyDialog": {
"title": "Проверка пароля профиля",
"description": "Введите пароль профиля, чтобы убедиться, что он совпадает с сохранённым на диске.",
"submit": "Проверить",
"matchToast": "Пароль верен"
}
},
"backendErrors": {
@@ -1749,7 +1796,6 @@
},
"rail": {
"profiles": "Профили",
"proxies": "Прокси",
"extensions": "Расширения",
"groups": "Группы",
"settings": "Настройки",
@@ -1758,21 +1804,23 @@
"closeAriaLabel": "Закрыть меню",
"importProfile": "Импорт профиля",
"importProfileHint": "Перенести профили из другого инструмента",
"integrations": "Интеграции",
"integrationsHint": "Slack, MCP, автоматизации",
"account": "Аккаунт",
"accountHint": "Облако, оплата, вход"
}
"keyboardShortcuts": "Сочетания клавиш",
"keyboardShortcutsHint": "Показать все сочетания"
},
"network": "Сеть",
"integrations": "Интеграции",
"account": "Аккаунт"
},
"pageTitle": {
"proxies": "Прокси",
"proxies": "Сеть",
"extensions": "Расширения",
"groups": "Группы",
"vpns": "VPN",
"vpns": "Сеть",
"settings": "Настройки",
"integrations": "Интеграции",
"account": "Аккаунт",
"import": "Импорт профиля"
"import": "Импорт профиля",
"shortcuts": "Сочетания клавиш"
},
"encryption": {
"required": {
@@ -1825,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": "Перейти к Настройкам"
}
}
+113 -34
View File
@@ -32,7 +32,8 @@
"downloading": "下载中...",
"minimize": "最小化",
"saving": "正在保存…",
"saved": "已保存"
"saved": "已保存",
"copied": "已复制"
},
"status": {
"active": "活跃",
@@ -158,7 +159,15 @@
"passwordSaved": "加密密码已设置",
"passwordMismatch": "密码不匹配",
"passwordTooShort": "密码必须至少8个字符",
"requiresProOrOwner": "配置文件加密仅适用于Pro用户和团队所有者。"
"requiresProOrOwner": "配置文件加密仅适用于Pro用户和团队所有者。",
"validatePassword": "验证",
"validateDialog": {
"title": "验证加密密码",
"description": "输入加密密码以验证它是否与此设备上存储的密码匹配。",
"submit": "验证",
"matchToast": "密码正确",
"mismatchToast": "密码不匹配"
}
},
"commercial": {
"title": "商业许可",
@@ -376,6 +385,9 @@
"deleteFailed": "删除代理失败",
"deleteTitle": "删除代理",
"deleteDescription": "此操作无法撤消。代理「{{name}}」将被永久删除。",
"newProxy": "新建代理",
"newVpn": "新建 VPN",
"protocolCol": "协议",
"title": "代理和 VPN"
},
"add": "添加代理",
@@ -480,6 +492,13 @@
"continueButton": "继续",
"doneButton": "完成",
"failed": "导入代理失败"
},
"bulkDelete": {
"proxiesTitle": "删除所选代理",
"proxiesDescription": "此操作无法撤销。将永久删除 {{count}} 个代理:{{names}}。",
"vpnsTitle": "删除所选 VPN",
"vpnsDescription": "此操作无法撤销。将永久删除 {{count}} 个 VPN{{names}}。",
"confirmButton": "删除 {{count}}"
}
},
"groups": {
@@ -488,10 +507,8 @@
"add": "添加分组",
"edit": "编辑分组",
"delete": "删除分组",
"defaultGroup": "默认",
"defaultGroupNoGroup": "默认(无分组)",
"moveToDefault": "将配置文件移至默认分组",
"noGroupDescription": "未分组的配置文件将显示在「默认」分组中。",
"moveToDefault": "将配置文件移出分组",
"noGroupDescription": "未分组的配置文件显示在「全部」筛选中。",
"assignSuccess": "已成功将 {{count}} 个配置文件分配到 {{group}}",
"noGroups": "暂无分组",
"noGroupsDescription": "创建分组来组织您的配置文件。",
@@ -521,7 +538,6 @@
"loadingProfiles": "正在加载关联的配置文件...",
"associatedProfiles": "关联的配置文件 ({{count}})",
"whatToDoWithProfiles": "这些配置文件应该怎么办?",
"moveToDefaultOption": "将配置文件移至默认组",
"deleteAlongWithGroup": "将配置文件与组一起删除",
"noAssociatedProfiles": "此组没有关联的配置文件。",
"deleteGroup": "删除组",
@@ -530,7 +546,10 @@
"unknownGroup": "未知分组",
"profileGroupsAriaLabel": "配置文件分组",
"loading": "正在加载组...",
"all": "全部"
"all": "全部",
"noGroup": "无分组",
"pageTitle": "配置文件分组",
"pageDescription": "配置文件分组可让您按客户端、环境或使用场景整理浏览器。在多台设备之间同步分组以便共享。"
},
"sync": {
"mode": {
@@ -637,19 +656,20 @@
"tokenCopied": "令牌已复制",
"url": "MCP 服务器 URL",
"urlCopied": "URL 已复制",
"claudeDesktopTitle": "Claude Desktop",
"claudeDesktopHint": "自动配置 claude_desktop_config.json",
"addToClaudeDesktop": "添加到 Claude Desktop",
"removeFromClaudeDesktop": "从 Claude Desktop 移除",
"addedToClaudeDesktop": "已添加到 Claude Desktop。请重启 Claude Desktop 并在设置中启用扩展。",
"removedFromClaudeDesktop": "已从 Claude Desktop 配置移除。请重启 Claude Desktop。",
"claudeCodeTitle": "Claude Code",
"addToClaudeCode": "添加到 Claude Code",
"removeFromClaudeCode": "从 Claude Code 移除",
"addedToClaudeCode": "已添加到 Claude Code",
"removedFromClaudeCode": "已从 Claude Code 移除",
"config": "MCP 配置",
"copyConfig": "复制配置"
"copyConfig": "复制配置",
"clientsLabel": "客户端",
"connected": "已连接",
"add": "添加",
"addedToClient": "已添加到 {{name}}",
"removedFromClient": "已从 {{name}} 移除",
"removeAriaLabel": "从 {{name}} 移除",
"category": {
"desktopApp": "桌面应用",
"cli": "CLI",
"editor": "编辑器",
"editorExt": "编辑器扩展"
}
},
"tabApi": "本地 API",
"tabMcp": "MCP (AI 助手)",
@@ -675,7 +695,9 @@
"mcpStarted": "MCP 服务器已在端口 {{port}} 上启动",
"mcpStopped": "MCP 服务器已停止",
"mcpToggleFailed": "切换 MCP 服务器失败",
"openSettings": "打开集成设置"
"openSettings": "打开集成设置",
"apiRunningOn": "运行于",
"apiExampleRequest": "示例请求"
},
"import": {
"title": "导入配置文件",
@@ -1181,6 +1203,7 @@
"empty": "尚未上传任何扩展程序。",
"noGroups": "尚未创建任何扩展程序组。",
"createGroup": "创建分组",
"newGroup": "新建分组",
"addToGroup": "添加扩展程序...",
"removeFromGroup": "从分组中移除",
"deleteGroup": "删除分组",
@@ -1228,7 +1251,14 @@
"syncEnableTooltip": "启用同步",
"syncDisableTooltip": "禁用同步",
"loadGroupsFailed": "加载扩展组失败",
"assignGroupFailed": "分配扩展组失败"
"assignGroupFailed": "分配扩展组失败",
"bulkDelete": {
"extensionsTitle": "删除扩展",
"extensionsDescription": "删除 {{count}} 个扩展?{{names}}",
"groupsTitle": "删除扩展组",
"groupsDescription": "删除 {{count}} 个扩展组?{{names}}",
"confirmButton": "删除"
}
},
"pro": {
"badge": "PRO",
@@ -1373,7 +1403,11 @@
"waiting": "等待同步",
"errorWith": "同步错误: {{error}}",
"error": "同步错误",
"notSynced": "未同步"
"notSynced": "未同步",
"enable": "启用同步",
"disable": "禁用同步",
"lockedInUse": "同步配置文件正在使用,无法禁用同步",
"bulkToggle": "切换同步"
},
"groupManagement": {
"description": "管理你的配置文件分组",
@@ -1387,7 +1421,13 @@
"syncCannotDisable": "此分组被同步的配置文件使用时无法禁用同步",
"editGroupTooltip": "编辑分组",
"deleteGroupTooltip": "删除分组",
"loadFailed": "加载分组失败"
"loadFailed": "加载分组失败",
"bulkDelete": {
"title": "删除分组",
"description": "确定要删除 {{count}} 个分组吗?{{names}}。配置文件将被移至默认分组。",
"description_one": "确定要删除 {{count}} 个分组吗?{{names}}。配置文件将被移至默认分组。",
"confirmButton": "删除分组"
}
},
"proxyAssignment": {
"title": "分配代理 / VPN",
@@ -1721,7 +1761,14 @@
"modes": {
"set": "设置",
"change": "更改",
"remove": "删除"
"remove": "删除",
"validate": "验证"
},
"verifyDialog": {
"title": "验证配置文件密码",
"description": "输入配置文件密码以确认它与磁盘上存储的密码匹配。",
"submit": "验证",
"matchToast": "密码正确"
}
},
"backendErrors": {
@@ -1749,7 +1796,6 @@
},
"rail": {
"profiles": "配置文件",
"proxies": "代理",
"extensions": "扩展",
"groups": "分组",
"settings": "设置",
@@ -1758,21 +1804,23 @@
"closeAriaLabel": "关闭菜单",
"importProfile": "导入配置文件",
"importProfileHint": "从其他工具导入",
"integrations": "集成",
"integrationsHint": "Slack、MCP、自动化",
"account": "账户",
"accountHint": "云、订阅、登录"
}
"keyboardShortcuts": "键盘快捷键",
"keyboardShortcutsHint": "查看所有快捷键"
},
"network": "网络",
"integrations": "集成",
"account": "账号"
},
"pageTitle": {
"proxies": "代理",
"proxies": "网络",
"extensions": "扩展",
"groups": "分组",
"vpns": "VPN",
"vpns": "网络",
"settings": "设置",
"integrations": "集成",
"account": "账户",
"import": "导入配置文件"
"import": "导入配置文件",
"shortcuts": "键盘快捷键"
},
"encryption": {
"required": {
@@ -1825,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": "转到设置"
}
}
+189
View File
@@ -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;
}
+37 -11
View File
@@ -157,26 +157,41 @@
}
}
/* Scroll-fade utility: a vertical mask whose top/bottom 16px fade to
transparent ONLY when the matching direction is scrollable. The component
sets `data-fade-top` / `data-fade-bottom` attributes on its container as
the user scrolls; each attribute toggles its own end of the mask via a
CSS variable, so the two edges are independent. */
/* Scroll-fade utility: a vertical mask that thins the alpha of the top and
bottom 24px of the scroll container ONLY when that direction is actually
scrollable. useScrollFade() writes `data-fade-top` / `data-fade-bottom`
on the container as the user scrolls; each attribute toggles its own
end of the mask via a CSS variable.
Mask is preferred over a colored gradient overlay: an overlay paints
bg-color over content, which leaves a visible band wherever content
passes through it. Mask just fades alpha content gracefully fades to
nothing at the edges.
`--scroll-fade-top-offset` pushes the top-edge fade band down by N
pixels so a sticky table header stays fully opaque and only the body
rows scrolling past it fade. Set it inline on a scroll container whose
first N px are occupied by sticky chrome. */
.scroll-fade {
--top-mask: black;
--bottom-mask: black;
--scroll-fade-top-offset: 0px;
-webkit-mask-image: linear-gradient(
to bottom,
var(--top-mask),
black 16px,
black calc(100% - 16px),
black 0,
black var(--scroll-fade-top-offset),
var(--top-mask) var(--scroll-fade-top-offset),
black calc(var(--scroll-fade-top-offset) + 24px),
black calc(100% - 24px),
var(--bottom-mask)
);
mask-image: linear-gradient(
to bottom,
var(--top-mask),
black 16px,
black calc(100% - 16px),
black 0,
black var(--scroll-fade-top-offset),
var(--top-mask) var(--scroll-fade-top-offset),
black calc(var(--scroll-fade-top-offset) + 24px),
black calc(100% - 24px),
var(--bottom-mask)
);
}
@@ -206,3 +221,14 @@
[data-sonner-toast] select {
pointer-events: auto;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms;
animation-iteration-count: 1;
transition-duration: 0.01ms;
scroll-behavior: auto;
}
}