mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-24 15:39:56 +02:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e54bc1192d | |||
| 9061e4db8f | |||
| 0da8529e07 | |||
| b3373924e6 | |||
| 19e50324c4 | |||
| 931d02fefd | |||
| 7b39c5dea9 | |||
| 8588a44fb5 | |||
| fe3ae13928 | |||
| 94cccc3702 | |||
| 9edc154397 | |||
| f29b161cf4 | |||
| 4007dedcf0 | |||
| 50d2834634 | |||
| f8791a9ec5 | |||
| 4598b22af1 | |||
| 4ac4c6e8a9 | |||
| 5a82b18fb8 | |||
| 5fada3f929 | |||
| 828a604c9d | |||
| 02328e59a2 | |||
| 577ab79fd0 | |||
| 8c221d02fe | |||
| e1b79037bf | |||
| 57036bdc95 | |||
| d3169ad7a9 | |||
| e1fcfd5403 | |||
| 9dc9e13182 | |||
| c5a168ae0f | |||
| 168b7ac6d4 | |||
| e5910ad5cf | |||
| 202f2c852b | |||
| 5a8864654d | |||
| ba40458216 | |||
| 91e6381ba5 | |||
| 2055108578 | |||
| fc9a00b97d | |||
| 15f3aa03f7 | |||
| 6b31c937ea | |||
| 96e4f22e38 | |||
| ef7af59ef8 | |||
| 3df5bffdf5 | |||
| e98d02a585 | |||
| afa2326584 | |||
| d25d8549e4 | |||
| 662b370ed0 | |||
| b2d16c7be1 | |||
| a0244356bf | |||
| 14522c75f6 | |||
| b4624f8e8f | |||
| e5f12884de | |||
| c95b097c93 | |||
| 742b883090 | |||
| 57e068084e | |||
| e006d56387 | |||
| 43f9f02029 | |||
| 839265de35 | |||
| 0d85b61c96 | |||
| f581b6ec59 | |||
| 43c86c2dfb | |||
| ddfdf68dd1 |
@@ -31,10 +31,10 @@ jobs:
|
||||
build-mode: none
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
name: Compliance Close
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 30 minutes; the actual close decision uses comment age, so the cron
|
||||
# cadence only bounds how stale the closure can get past the 24-hour mark.
|
||||
- cron: "*/30 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
close-non-compliant:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close non-compliant issues and PRs after 24 hours
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { data: items } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: 'needs:compliance',
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
core.info('No open issues/PRs with needs:compliance label');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const window_ms = 24 * 60 * 60 * 1000;
|
||||
|
||||
for (const item of items) {
|
||||
const isPR = !!item.pull_request;
|
||||
const kind = isPR ? 'PR' : 'issue';
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: item.number,
|
||||
});
|
||||
|
||||
// Use the OLDEST compliance sentinel as the start of the 24-hour
|
||||
// window so back-and-forth edits don't reset the clock.
|
||||
const sentinel = comments
|
||||
.filter(c => c.body && c.body.includes('<!-- issue-compliance -->'))
|
||||
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at))[0];
|
||||
|
||||
if (!sentinel) {
|
||||
core.info(`${kind} #${item.number} has needs:compliance label but no compliance comment; skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const age_ms = now - new Date(sentinel.created_at).getTime();
|
||||
if (age_ms < window_ms) {
|
||||
const hours = (age_ms / (60 * 60 * 1000)).toFixed(1);
|
||||
core.info(`${kind} #${item.number} still within 24-hour window (${hours}h elapsed)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const closeMessage = isPR
|
||||
? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/main/CONTRIBUTING.md) within the 24-hour window.\n\nFeel free to open a new pull request that follows our guidelines.'
|
||||
: 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/main/CONTRIBUTING.md) within the 24-hour window.\n\nFeel free to open a new issue that follows our issue templates.';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: item.number,
|
||||
body: closeMessage,
|
||||
});
|
||||
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: item.number,
|
||||
name: 'needs:compliance',
|
||||
});
|
||||
} catch (e) {
|
||||
core.info(`Could not remove needs:compliance label from #${item.number}: ${e.message}`);
|
||||
}
|
||||
|
||||
if (isPR) {
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: item.number,
|
||||
state: 'closed',
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: item.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
}
|
||||
|
||||
core.info(`Closed non-compliant ${kind} #${item.number} after 24-hour window`);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
- name: Contribute List
|
||||
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
|
||||
env:
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@a6f7623b2e2401f485f1eead77ced45bd99b09b0 #v31
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Issue Compliance Check
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -13,11 +13,16 @@ env:
|
||||
|
||||
jobs:
|
||||
check-compliance:
|
||||
if: github.repository == 'zhom/donutbrowser' && github.event.action == 'opened'
|
||||
# Maintainers' own issues are exempt — they open quick tracking issues
|
||||
# without the template on purpose. Everyone else is checked.
|
||||
if: >-
|
||||
github.repository == 'zhom/donutbrowser' &&
|
||||
github.event.issue.author_association != 'OWNER' &&
|
||||
github.event.issue.author_association != 'MEMBER'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Gather context
|
||||
env:
|
||||
@@ -44,7 +49,7 @@ jobs:
|
||||
- A feature request gives no use case at all
|
||||
- The author left required fields empty (Operating System, Donut Browser version, Which browser is affected, Steps to reproduce on bug reports)
|
||||
|
||||
Do NOT flag for missing optional fields, missing screenshots, short titles, or stylistic issues. Be conservative.
|
||||
Do NOT flag for missing optional fields, missing screenshots, short titles, or stylistic issues. Be conservative — a non-compliant verdict closes the issue, so only flag a genuine template violation.
|
||||
|
||||
## Output schema
|
||||
{
|
||||
@@ -83,7 +88,7 @@ jobs:
|
||||
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/raw.txt
|
||||
|
||||
# Strip accidental markdown fences and parse. On parse failure, fall back
|
||||
# to a noop result so the workflow doesn't fail the issue author's run.
|
||||
# to a compliant result so a flaky model never closes a legitimate issue.
|
||||
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
|
||||
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
|
||||
echo "::warning::Model returned non-JSON; treating as compliant"
|
||||
@@ -94,6 +99,7 @@ jobs:
|
||||
cat /tmp/result.json
|
||||
|
||||
- name: Build comment
|
||||
id: build
|
||||
run: |
|
||||
python3 - <<'EOF'
|
||||
import json, os
|
||||
@@ -103,167 +109,25 @@ jobs:
|
||||
|
||||
parts = []
|
||||
if not compliant:
|
||||
parts.append('<!-- issue-compliance -->')
|
||||
parts.append("This issue doesn't fully meet our [contributing guidelines](../blob/main/CONTRIBUTING.md).")
|
||||
parts.append("This issue was automatically closed because it doesn't follow our [issue templates](../issues/new/choose).")
|
||||
parts.append('')
|
||||
parts.append('**What needs to be fixed:**')
|
||||
parts.append('**What was missing:**')
|
||||
for reason in reasons:
|
||||
parts.append(f'- {reason}')
|
||||
parts.append('')
|
||||
parts.append('Please edit this issue to address the above within **24 hours**, or it will be automatically closed.')
|
||||
parts.append('')
|
||||
parts.append('If you believe this was flagged incorrectly, please let a maintainer know.')
|
||||
parts.append('If this is a real bug or feature request, please open a new issue using the **Bug Report** or **Feature Request** template and fill in the required fields. Issues that ignore the template are not triaged.')
|
||||
|
||||
comment = '\n'.join(parts).strip()
|
||||
open('/tmp/comment.md', 'w').write(comment)
|
||||
with open(os.environ['GITHUB_OUTPUT'], 'a') as fh:
|
||||
fh.write(f'has_comment={"true" if comment else "false"}\n')
|
||||
fh.write(f'non_compliant={"true" if not compliant else "false"}\n')
|
||||
EOF
|
||||
id: build
|
||||
|
||||
- name: Post comment
|
||||
if: steps.build.outputs.has_comment == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
|
||||
|
||||
- name: Apply needs:compliance label
|
||||
- name: Comment and close non-compliant issue
|
||||
if: steps.build.outputs.non_compliant == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "needs:compliance"
|
||||
|
||||
recheck-compliance:
|
||||
# When a flagged issue is edited, re-check. If now compliant: remove label,
|
||||
# delete the previous compliance comment, and thank the author. If still
|
||||
# non-compliant: leave label and post an updated note.
|
||||
if: >
|
||||
github.repository == 'zhom/donutbrowser' &&
|
||||
github.event.action == 'edited' &&
|
||||
contains(github.event.issue.labels.*.name, 'needs:compliance')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Gather context
|
||||
env:
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
run: |
|
||||
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
|
||||
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
|
||||
|
||||
- name: Build prompt
|
||||
run: |
|
||||
cat > /tmp/system.txt <<'PROMPT'
|
||||
You are re-checking a GitHub issue that was previously flagged as not meeting template requirements. Return ONLY a single JSON object, no prose, no markdown fences.
|
||||
|
||||
Project: Donut Browser. There are three valid templates:
|
||||
- Bug Report (Description + Operating System + Donut Browser version + Which browser is affected + Steps to reproduce + Error logs/screenshots fields)
|
||||
- Feature Request (description + verification checkbox)
|
||||
- Question (free form)
|
||||
|
||||
## Flag NON-compliant ONLY when at least one of these is true
|
||||
- The issue body is empty or contains only placeholder text from the template
|
||||
- The issue is an obvious AI-generated wall of text with no real specifics
|
||||
- A bug report has no reproduction information or no error description
|
||||
- A feature request gives no use case at all
|
||||
- The author left required fields empty (Operating System, Donut Browser version, Which browser is affected, Steps to reproduce on bug reports)
|
||||
|
||||
Do NOT flag for missing optional fields, missing screenshots, short titles, or stylistic issues. Be conservative.
|
||||
|
||||
## Output schema
|
||||
{
|
||||
"is_compliant": true | false,
|
||||
"non_compliance_reasons": ["short bullet", ...]
|
||||
}
|
||||
PROMPT
|
||||
|
||||
- name: Call OpenRouter
|
||||
env:
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
run: |
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg model "$MODEL" \
|
||||
--rawfile system_prompt /tmp/system.txt \
|
||||
--rawfile title /tmp/issue-title.txt \
|
||||
--rawfile body /tmp/issue-body.txt \
|
||||
'{
|
||||
model: $model,
|
||||
messages: [
|
||||
{ role: "system", content: $system_prompt },
|
||||
{ role: "user", content: ("Title: " + $title + "\n\nBody:\n" + $body) }
|
||||
],
|
||||
response_format: { type: "json_object" }
|
||||
}')
|
||||
|
||||
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")
|
||||
|
||||
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/raw.txt
|
||||
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
|
||||
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
|
||||
echo "::warning::Model returned non-JSON; assuming still non-compliant"
|
||||
echo '{"is_compliant": false, "non_compliance_reasons": ["unable to parse model output"]}' > /tmp/result.json
|
||||
fi
|
||||
|
||||
- name: Resolve compliance state
|
||||
id: resolve
|
||||
run: |
|
||||
IS_COMPLIANT=$(jq -r '.is_compliant // false' /tmp/result.json)
|
||||
echo "is_compliant=$IS_COMPLIANT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Clear compliance label and acknowledge fix
|
||||
if: steps.resolve.outputs.is_compliant == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --remove-label "needs:compliance" || true
|
||||
|
||||
# Delete the previous <!-- issue-compliance --> sentinel comment so
|
||||
# the thread is clean once the author has addressed the issue.
|
||||
COMMENT_ID=$(gh api "repos/$GITHUB_REPOSITORY/issues/$ISSUE_NUMBER/comments" \
|
||||
--jq '[.[] | select(.body | contains("<!-- issue-compliance -->"))][-1].id // empty')
|
||||
if [ -n "$COMMENT_ID" ]; then
|
||||
gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID" || true
|
||||
fi
|
||||
|
||||
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" \
|
||||
--body "Thanks for updating the issue."
|
||||
|
||||
- name: Build follow-up comment
|
||||
if: steps.resolve.outputs.is_compliant != 'true'
|
||||
run: |
|
||||
python3 - <<'EOF'
|
||||
import json
|
||||
r = json.load(open('/tmp/result.json'))
|
||||
reasons = r.get('non_compliance_reasons') or []
|
||||
parts = [
|
||||
'<!-- issue-compliance -->',
|
||||
'This issue still does not meet our [contributing guidelines](../blob/main/CONTRIBUTING.md).',
|
||||
'',
|
||||
'**What still needs to be fixed:**',
|
||||
]
|
||||
for reason in reasons:
|
||||
parts.append(f'- {reason}')
|
||||
parts.append('')
|
||||
parts.append('Please edit this issue to address the above within **24 hours**, or it will be automatically closed.')
|
||||
open('/tmp/comment.md', 'w').write('\n'.join(parts))
|
||||
EOF
|
||||
|
||||
- name: Post follow-up comment
|
||||
if: steps.resolve.outputs.is_compliant != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
|
||||
gh issue close "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --reason "not planned"
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Check if first-time contributor
|
||||
id: check-first-time
|
||||
@@ -479,7 +479,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Check if first-time contributor
|
||||
id: check-first-time
|
||||
@@ -617,10 +617,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@385cb694419f98103af0e8fc6187ddcbcbb6eecb #v1.15.13
|
||||
uses: anomalyco/opencode/github@11e47f91496005aab4d7c5a2d0a7da5d2651b4ac #v1.17.8
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -34,10 +34,10 @@ jobs:
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -41,10 +41,10 @@ jobs:
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -23,6 +23,9 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Determine release tag
|
||||
id: tag
|
||||
env:
|
||||
@@ -40,182 +43,35 @@ jobs:
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Configure aws-cli for R2
|
||||
# aws-cli v2.23+ sends integrity checksums by default; Cloudflare R2
|
||||
# rejects those headers with `Unauthorized` on ListObjectsV2.
|
||||
# Also normalise the endpoint URL (must start with https://).
|
||||
# Both values propagate to later steps via $GITHUB_ENV.
|
||||
env:
|
||||
RAW_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
|
||||
run: |
|
||||
endpoint="$RAW_ENDPOINT"
|
||||
if [[ "$endpoint" != https://* && "$endpoint" != http://* ]]; then
|
||||
endpoint="https://$endpoint"
|
||||
fi
|
||||
echo "R2_ENDPOINT=$endpoint" >> "$GITHUB_ENV"
|
||||
echo "AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED" >> "$GITHUB_ENV"
|
||||
echo "AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_REQUIRED" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
# Mirror the local/Docker setup from CLAUDE.md exactly: the same apt
|
||||
# packages and the same pip-installed awscli the working local run uses.
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y dpkg-dev createrepo-c python3-pip
|
||||
# Remove pre-installed aws-cli v2 — it sends CRC64NVME checksums
|
||||
# that Cloudflare R2 rejects with Unauthorized, and the s3transfer
|
||||
# lib has a confirmed bug where WHEN_REQUIRED is silently ignored
|
||||
# (boto/s3transfer#327). Install aws-cli v1 via pip instead.
|
||||
sudo rm -f /usr/local/bin/aws /usr/local/bin/aws_completer
|
||||
sudo rm -rf /usr/local/aws-cli
|
||||
pip3 install --break-system-packages awscli
|
||||
# Ensure pip-installed aws is on PATH (pip may install to ~/.local/bin)
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
aws --version
|
||||
|
||||
- name: Download packages from GitHub release
|
||||
- name: Publish DEB & RPM repositories to R2
|
||||
env:
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}
|
||||
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
run: |
|
||||
mkdir -p /tmp/packages
|
||||
gh release download "$TAG" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--pattern "*.deb" \
|
||||
--dir /tmp/packages
|
||||
gh release download "$TAG" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--pattern "*.rpm" \
|
||||
--dir /tmp/packages
|
||||
echo "Downloaded packages:"
|
||||
ls -lh /tmp/packages/
|
||||
|
||||
- name: Build DEB repository
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
DEB_DIR="/tmp/repo/deb"
|
||||
mkdir -p "$DEB_DIR/pool/main"
|
||||
mkdir -p "$DEB_DIR/dists/stable/main/binary-amd64"
|
||||
mkdir -p "$DEB_DIR/dists/stable/main/binary-arm64"
|
||||
|
||||
# Sync existing pool from R2 (incremental)
|
||||
aws s3 sync "s3://${R2_BUCKET}/deb/pool" "$DEB_DIR/pool" \
|
||||
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || true
|
||||
|
||||
# Copy new .deb files into pool
|
||||
cp /tmp/packages/*.deb "$DEB_DIR/pool/main/" 2>/dev/null || true
|
||||
|
||||
# Generate Packages and Packages.gz for each arch
|
||||
for arch in amd64 arm64; do
|
||||
BINARY_DIR="$DEB_DIR/dists/stable/main/binary-${arch}"
|
||||
(cd "$DEB_DIR" && dpkg-scanpackages --arch "$arch" pool/main) \
|
||||
> "$BINARY_DIR/Packages"
|
||||
gzip -9c "$BINARY_DIR/Packages" > "$BINARY_DIR/Packages.gz"
|
||||
echo " $arch: $(grep -c '^Package:' "$BINARY_DIR/Packages" 2>/dev/null || echo 0) package(s)"
|
||||
done
|
||||
|
||||
# Generate Release file
|
||||
{
|
||||
echo "Origin: Donut Browser"
|
||||
echo "Label: Donut Browser"
|
||||
echo "Suite: stable"
|
||||
echo "Codename: stable"
|
||||
echo "Architectures: amd64 arm64"
|
||||
echo "Components: main"
|
||||
echo "Date: $(date -u '+%a, %d %b %Y %H:%M:%S UTC')"
|
||||
echo "MD5Sum:"
|
||||
for arch in amd64 arm64; do
|
||||
for file in "main/binary-${arch}/Packages" "main/binary-${arch}/Packages.gz"; do
|
||||
filepath="$DEB_DIR/dists/stable/$file"
|
||||
if [[ -f "$filepath" ]]; then
|
||||
size=$(wc -c < "$filepath")
|
||||
md5=$(md5sum "$filepath" | awk '{print $1}')
|
||||
printf " %s %8d %s\n" "$md5" "$size" "$file"
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo "SHA256:"
|
||||
for arch in amd64 arm64; do
|
||||
for file in "main/binary-${arch}/Packages" "main/binary-${arch}/Packages.gz"; do
|
||||
filepath="$DEB_DIR/dists/stable/$file"
|
||||
if [[ -f "$filepath" ]]; then
|
||||
size=$(wc -c < "$filepath")
|
||||
sha256=$(sha256sum "$filepath" | awk '{print $1}')
|
||||
printf " %s %8d %s\n" "$sha256" "$size" "$file"
|
||||
fi
|
||||
done
|
||||
done
|
||||
} > "$DEB_DIR/dists/stable/Release"
|
||||
|
||||
echo "DEB Release file created."
|
||||
|
||||
- name: Build RPM repository
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
RPM_DIR="/tmp/repo/rpm"
|
||||
mkdir -p "$RPM_DIR/x86_64"
|
||||
mkdir -p "$RPM_DIR/aarch64"
|
||||
|
||||
# Sync existing RPMs from R2 (incremental)
|
||||
aws s3 sync "s3://${R2_BUCKET}/rpm/x86_64" "$RPM_DIR/x86_64" \
|
||||
--endpoint-url "$R2_ENDPOINT" --exclude "repodata/*" 2>/dev/null || true
|
||||
aws s3 sync "s3://${R2_BUCKET}/rpm/aarch64" "$RPM_DIR/aarch64" \
|
||||
--endpoint-url "$R2_ENDPOINT" --exclude "repodata/*" 2>/dev/null || true
|
||||
|
||||
# Copy new .rpm files into arch directories
|
||||
for rpm in /tmp/packages/*.rpm; do
|
||||
[[ -f "$rpm" ]] || continue
|
||||
filename=$(basename "$rpm")
|
||||
if [[ "$filename" == *x86_64* ]]; then
|
||||
cp "$rpm" "$RPM_DIR/x86_64/"
|
||||
elif [[ "$filename" == *aarch64* ]]; then
|
||||
cp "$rpm" "$RPM_DIR/aarch64/"
|
||||
fi
|
||||
done
|
||||
|
||||
# Generate repodata
|
||||
createrepo_c --update "$RPM_DIR"
|
||||
echo "RPM repodata created."
|
||||
|
||||
- name: Upload to R2
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
echo "Uploading DEB repository..."
|
||||
aws s3 sync /tmp/repo/deb/dists "s3://${R2_BUCKET}/deb/dists" \
|
||||
--endpoint-url "$R2_ENDPOINT" --delete
|
||||
aws s3 sync /tmp/repo/deb/pool "s3://${R2_BUCKET}/deb/pool" \
|
||||
--endpoint-url "$R2_ENDPOINT"
|
||||
|
||||
echo "Uploading RPM repository..."
|
||||
aws s3 sync /tmp/repo/rpm "s3://${R2_BUCKET}/rpm" \
|
||||
--endpoint-url "$R2_ENDPOINT"
|
||||
|
||||
- name: Verify upload
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
run: |
|
||||
echo "Published repos for $TAG"
|
||||
echo ""
|
||||
echo "DEB dists/stable/:"
|
||||
aws s3 ls "s3://${R2_BUCKET}/deb/dists/stable/" \
|
||||
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty)"
|
||||
echo "DEB pool/main/:"
|
||||
aws s3 ls "s3://${R2_BUCKET}/deb/pool/main/" \
|
||||
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty)"
|
||||
echo "RPM repodata/:"
|
||||
aws s3 ls "s3://${R2_BUCKET}/rpm/repodata/" \
|
||||
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty)"
|
||||
# GitHub injects secrets verbatim. If a value was pasted with
|
||||
# surrounding quotes or a trailing newline — the local .env wraps all
|
||||
# four R2_* values in double quotes — it reaches the script malformed:
|
||||
# e.g. an endpoint of https://"host" yields
|
||||
# `Could not connect to the endpoint URL`, and a quoted key yields
|
||||
# `Unauthorized`. The local run is unaffected because publish-repo.sh
|
||||
# sources .env through bash, which strips the quotes; CI has no .env,
|
||||
# so strip here. No-op when the secrets are already clean. The script
|
||||
# itself is intentionally left untouched.
|
||||
strip() { printf '%s' "$1" | tr -d '\r\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/"; }
|
||||
export R2_ACCESS_KEY_ID="$(strip "$R2_ACCESS_KEY_ID")"
|
||||
export R2_SECRET_ACCESS_KEY="$(strip "$R2_SECRET_ACCESS_KEY")"
|
||||
export R2_ENDPOINT_URL="$(strip "$R2_ENDPOINT_URL")"
|
||||
export R2_BUCKET_NAME="$(strip "$R2_BUCKET_NAME")"
|
||||
bash scripts/publish-repo.sh "${{ steps.tag.outputs.tag }}"
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -105,10 +105,10 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -148,12 +148,12 @@ jobs:
|
||||
- name: Verify frontend dist exists
|
||||
shell: bash
|
||||
run: |
|
||||
if [ ! -d "dist" ]; then
|
||||
echo "Error: dist directory not found after build"
|
||||
ls -la
|
||||
if [ ! -f "dist/index.html" ]; then
|
||||
echo "Error: dist/index.html not found after build (static export incomplete)"
|
||||
ls -la dist 2>/dev/null || ls -la
|
||||
exit 1
|
||||
fi
|
||||
echo "Frontend dist directory verified at $(pwd)/dist"
|
||||
echo "Frontend dist verified at $(pwd)/dist (index.html present)"
|
||||
echo "Checking from src-tauri perspective:"
|
||||
ls -la src-tauri/../dist || echo "Warning: dist not accessible from src-tauri"
|
||||
|
||||
@@ -246,7 +246,12 @@ jobs:
|
||||
|
||||
# Copy sidecar binaries
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/"
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
|
||||
# The daemon is currently disabled (no Cargo bin target), so it isn't
|
||||
# built. Copy it only if a build produced it, so the absent binary
|
||||
# doesn't fail the job.
|
||||
if [ -f "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" ]; then
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
|
||||
fi
|
||||
|
||||
# Copy WebView2Loader if present
|
||||
if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
|
||||
@@ -283,7 +288,7 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -449,7 +454,7 @@ jobs:
|
||||
needs: [release, changelog]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -547,7 +552,7 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
with:
|
||||
ref: main
|
||||
|
||||
|
||||
@@ -104,10 +104,10 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -247,7 +247,12 @@ jobs:
|
||||
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/donutbrowser.exe" "$PORTABLE_DIR/Donut.exe"
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/"
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
|
||||
# The daemon is currently disabled (no Cargo bin target), so it isn't
|
||||
# built. Copy it only if a build produced it, so the absent binary
|
||||
# doesn't fail the job.
|
||||
if [ -f "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" ]; then
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
|
||||
fi
|
||||
|
||||
if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" "$PORTABLE_DIR/"
|
||||
@@ -279,7 +284,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
|
||||
- name: Generate nightly tag
|
||||
id: tag
|
||||
|
||||
@@ -21,6 +21,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@f8a58b6b53f2279f71eb605f03a4ae4d10608f45 #v1.47.0
|
||||
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 #v1.47.2
|
||||
|
||||
@@ -22,3 +22,6 @@ jobs:
|
||||
stale-pr-label: "stale"
|
||||
days-before-stale: 30
|
||||
days-before-close: 7
|
||||
# Never let the maintainer's own assigned issues go stale or get
|
||||
# closed, regardless of inactivity.
|
||||
exempt-issue-assignees: "zhom"
|
||||
|
||||
@@ -32,10 +32,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v7.0.0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v7.0.0
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
# ⛔ ABSOLUTE GIT RULE — READ FIRST (2026-06-11)
|
||||
|
||||
**NEVER run any git command that modifies git history OR the working tree, in ANY repo** (wayfern, wayfern-macos, wayfern-test, donutbrowser, build/src), **unless the user EXPLICITLY authorizes that exact command.** Forbidden without per-command authorization: `commit`, `revert`, `cherry-pick`, `restore`, `checkout` (files/branches), `reset`, `rebase`, `merge`, `stash`, `clean`, `apply`, `add`, `rm`, `push`, any force op. Only read-only git (`status`, `log`, `show`, `diff`, `ls-files`, `rev-parse`) is allowed without asking. **Authorization is per-command: 1 explicit authorization = exactly 1 command.** If a git mutation seems needed, STOP and ask for that one command.
|
||||
|
||||
---
|
||||
|
||||
# Project Guidelines
|
||||
|
||||
> **NOTE**: CLAUDE.md is a symlink to AGENTS.md — editing either file updates both.
|
||||
@@ -11,7 +17,7 @@ donutbrowser/
|
||||
│ ├── app/ # App router (page.tsx, layout.tsx)
|
||||
│ ├── components/ # 50+ React components (dialogs, tables, UI)
|
||||
│ ├── hooks/ # Event-driven React hooks
|
||||
│ ├── i18n/locales/ # Translations (en, es, fr, ja, pt, ru, zh)
|
||||
│ ├── i18n/locales/ # Translations (en, es, fr, ja, ko, pt, ru, vi, zh)
|
||||
│ ├── lib/ # Utilities (themes, toast, browser-utils)
|
||||
│ └── types.ts # Shared TypeScript interfaces
|
||||
├── src-tauri/ # Rust backend (Tauri)
|
||||
@@ -27,9 +33,7 @@ donutbrowser/
|
||||
│ │ ├── mcp_server.rs # MCP protocol server
|
||||
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
|
||||
│ │ ├── vpn/ # WireGuard tunnels
|
||||
│ │ ├── camoufox/ # Camoufox fingerprint engine (Bayesian network)
|
||||
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
|
||||
│ │ ├── camoufox_manager.rs # Camoufox (Firefox) browser management
|
||||
│ │ ├── downloader.rs # Browser binary downloader
|
||||
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
|
||||
│ │ ├── settings_manager.rs # App settings persistence
|
||||
@@ -60,9 +64,8 @@ donutbrowser/
|
||||
|
||||
Three log surfaces, in order of usefulness:
|
||||
|
||||
- **Donut Browser GUI** — `~/Library/Logs/com.donutbrowser/DonutBrowser.log` on macOS (newest = active session; older `DonutBrowser_<date>.log` are rotated). The GUI / Tauri / `browser_runner` / `proxy_manager` / `sync` all log here. Search for `Camoufox`, `Wayfern`, `Starting local proxy`, `Configured local proxy` to find a launch chain. Dev builds write to `DonutBrowserDev.log` instead.
|
||||
- **Donut Browser GUI** — `~/Library/Logs/com.donutbrowser/DonutBrowser.log` on macOS (newest = active session; older `DonutBrowser_<date>.log` are rotated). The GUI / Tauri / `browser_runner` / `proxy_manager` / `sync` all log here. Search for `Wayfern`, `Starting local proxy`, `Configured local proxy` to find a launch chain. Dev builds write to `DonutBrowserDev.log` instead.
|
||||
- **donut-proxy worker** — `$TMPDIR/donut-proxy-<config_id>.log`. One file per proxy worker process (each profile launch spawns a fresh one). Map a worker to its launch via the `Cleanup: browser PID X is dead, stopping proxy worker <id>` lines in DonutBrowser.log, or by mtime. CONNECT requests, upstream accept/reject (status lines like `HTTP/1.1 402 user reached limit`), and tunnel errors are at INFO/WARN — anything finer is at TRACE and requires `RUST_LOG=donut_proxy=trace`. The `Upstream CONNECT response coalesced N byte(s) of payload — these would be dropped without forwarding` warning marks a real bug in `handle_connect_from_buffer` if it ever fires.
|
||||
- **Camoufox stderr** — `$TMPDIR/camoufox-stderr-<profile_id>.log`, written by `camoufox_manager::launch_camoufox`. Captures NSS / GPU Helper / juggler errors. Firefox does **not** print TLS/network errors here by default — set `MOZ_LOG=nsHttp:5,signaling:5` on the env if you need that. The `RustSearch.sys.mjs missing field 'recordType'` lines are noise from our `search.json.mozlz4` schema being slightly off for FF150+; not a network problem.
|
||||
|
||||
Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropriate location (see `app_dirs::app_name()`), but the `$TMPDIR` worker logs are always under the system temp dir.
|
||||
|
||||
@@ -76,12 +79,12 @@ Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropria
|
||||
|
||||
- Never write user-facing strings as raw English literals in JSX, toast messages, dialog titles/descriptions, button labels, placeholders, table headers, tooltips, or empty-state text. Always go through `t("namespace.key")` from `useTranslation()`.
|
||||
- This applies to every component under `src/` — including new ones. If a component doesn't already import `useTranslation`, add it.
|
||||
- Adding a new string means adding the key to ALL seven locale files in `src/i18n/locales/` (en, es, fr, ja, pt, ru, zh) — not just `en.json`. The English version alone is incomplete work.
|
||||
- Adding a new string means adding the key to ALL nine locale files in `src/i18n/locales/` (en, es, fr, ja, ko, pt, ru, vi, zh) — not just `en.json`. The English version alone is incomplete work.
|
||||
- Reuse existing keys (`common.buttons.*`, `common.labels.*`, `createProfile.*`, etc.) before creating new namespaces. Check `en.json` first.
|
||||
- Strings excluded from this rule: `console.log/warn/error`, dev-only debug labels, internal IDs, CSS class names, type names. If unsure whether a string renders to the user, assume it does and translate it.
|
||||
- **Never use `t(key, "fallback")` with a default-value second argument.** The 2-arg form is forbidden — every key must exist in every locale file before the call site lands. Fallbacks mask missing translations: a key missing from `ru.json` will silently render the English fallback to Russian users, so the bug never surfaces in CI or review. Only call `t("namespace.key")`. If a translation is missing for any locale, that's a bug to fix at the JSON, not a hole to paper over at the call site.
|
||||
- Empty-string values in non-English locales are also forbidden — a locale either has the right translation or it has the same content as English; never `""`. If a particular language doesn't need a particular phrase (e.g. a suffix that doesn't grammatically apply), refactor the JSX to use a single interpolated key (`t("foo.bar", { name })` with `"...{{name}}..."` in each locale) instead of splitting prefix/suffix.
|
||||
- When adding or removing keys across all seven locales, use a one-shot Python script in `/tmp/` that loads each `*.json`, mutates it, and writes it back. Seven sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away.
|
||||
- When adding or removing keys across all nine locales, use a one-shot Python script in `/tmp/` that loads each `*.json`, mutates it, and writes it back. Nine sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away.
|
||||
|
||||
## Backend error codes (mandatory)
|
||||
|
||||
@@ -95,7 +98,7 @@ User-facing errors returned from a Tauri command MUST be JSON `{ "code": "FOO_BA
|
||||
```
|
||||
2. Add `"FOO_BAR"` to the `BackendErrorCode` union in `src/lib/backend-errors.ts`.
|
||||
3. Add a `case "FOO_BAR":` in the switch that returns `t("backendErrors.fooBar", …)`.
|
||||
4. Add `backendErrors.fooBar` to all seven locale files.
|
||||
4. Add `backendErrors.fooBar` to all nine locale files.
|
||||
|
||||
Raw error strings reach the user untranslated; that's the bug pattern this rule blocks.
|
||||
|
||||
@@ -148,7 +151,7 @@ Reference implementations: `proxy-management-dialog.tsx`, `extension-management-
|
||||
|
||||
All app-wide shortcuts live in `src/lib/shortcuts.ts`:
|
||||
|
||||
- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in all seven locales.
|
||||
- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in all nine locales.
|
||||
- `formatShortcut(s)` returns platform-correct token strings (`["⌘", "K"]` on mac, `["Ctrl", "K"]` elsewhere) — used by both the shortcuts page and the command palette.
|
||||
- `matchesShortcut(s, event)` matches a real `KeyboardEvent` and rejects the wrong-platform modifier so Ctrl+K on macOS never fires a `mod: true` shortcut.
|
||||
- `matchesGroupDigit(event)` returns 1–9 if Mod+digit was pressed — group switching is dynamic (driven by `orderedGroupTargets` in `page.tsx`) and isn't in the `SHORTCUTS` table.
|
||||
@@ -158,7 +161,7 @@ Dispatch: the global `keydown` listener and the `runShortcut` callback both live
|
||||
1. Append to `SHORTCUTS` in `src/lib/shortcuts.ts`. Add the `ShortcutId` variant.
|
||||
2. Add a `case "yourId":` in `runShortcut` in `page.tsx`.
|
||||
3. Add the icon mapping in `src/components/command-palette.tsx::ICONS`.
|
||||
4. Add `shortcuts.yourId` (label) to all seven locale files.
|
||||
4. Add `shortcuts.yourId` (label) to all nine locale files.
|
||||
|
||||
The command palette (Mod+K) is built on the shadcn `Command` primitive with a token-AND fuzzy filter — `fuzzyFilter` in `command-palette.tsx`. The `CommandDialog` wrapper now forwards `filter`/`shouldFilter` to the inner `Command` for callers that need custom matching.
|
||||
|
||||
|
||||
@@ -1,6 +1,102 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## v0.27.0 (2026-06-17)
|
||||
|
||||
### Features
|
||||
|
||||
- amek window resizable
|
||||
|
||||
### Refactoring
|
||||
|
||||
- better tray icon
|
||||
- simplify socks connection
|
||||
- switch local proxy from http to socks
|
||||
|
||||
### Documentation
|
||||
|
||||
- readme
|
||||
- readme
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- ci(deps): bump anomalyco/opencode in the github-actions group (#437)
|
||||
- chore: update flake.nix for v0.26.0 [skip ci] (#428)
|
||||
|
||||
|
||||
## v0.26.0 (2026-06-08)
|
||||
|
||||
### Features
|
||||
|
||||
- add cookie export
|
||||
|
||||
### Refactoring
|
||||
|
||||
- deprecate camoufox
|
||||
- cleanup
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: linting
|
||||
- ci(deps): bump the github-actions group with 3 updates (#421)
|
||||
- chore: update flake.nix for v0.25.3 [skip ci] (#417)
|
||||
|
||||
### Other
|
||||
|
||||
- deps(rust)(deps): bump the rust-dependencies group (#422)
|
||||
|
||||
|
||||
## v0.25.3 (2026-06-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- launch wayfern with proper dimentions for mobile devices
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: update flake.nix for v0.25.2 [skip ci] (#415)
|
||||
|
||||
|
||||
## v0.25.2 (2026-06-02)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- cleanup
|
||||
|
||||
### Documentation
|
||||
|
||||
- update CHANGELOG.md and README.md for v0.25.1 [skip ci] (#412)
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: simplify linux repo publish
|
||||
- chore: version bump
|
||||
- chore: copy
|
||||
- chore: update flake.nix for v0.25.1 [skip ci] (#413)
|
||||
|
||||
|
||||
## v0.25.1 (2026-06-01)
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: update issue validation
|
||||
- chore: cleanup windows ci
|
||||
- chore: add missing keys
|
||||
|
||||
|
||||
## v0.25.0 (2026-06-01)
|
||||
|
||||
Note: created manually due to CI issue
|
||||
|
||||
- Onboarding added for new users.
|
||||
- When closing the window, you can choose to minimize to tray or quit.
|
||||
- Improved feedback for macOS permission grants.
|
||||
- Cloud login now opens in your external browser.
|
||||
|
||||
## v0.24.4 (2026-05-26)
|
||||
|
||||
### Refactoring
|
||||
|
||||
+12
-12
@@ -1,6 +1,6 @@
|
||||
# Contributing to Donut Browser
|
||||
|
||||
Contributions are welcome! To start working on an issue, leave a comment indicating you're taking it on.
|
||||
Contributions are welcome! Please do not create PRs for the sake of being added to the contributors list. Reviewing PRs takes time, so please create PRs only if you believe that your change will improve Donut for yourself and others. If you are thinking of making a significant change, please get in touch with the maintainer first.
|
||||
|
||||
## Before Starting
|
||||
|
||||
@@ -49,12 +49,12 @@ pnpm format && pnpm lint && pnpm test
|
||||
|
||||
This runs:
|
||||
|
||||
- **Biome** — JS/TS linting and formatting
|
||||
- **Clippy + rustfmt** — Rust linting and formatting
|
||||
- **typos** — Spellcheck (allowlist in `_typos.toml`)
|
||||
- **CodeQL** — Security analysis (JS, Actions, Rust) — runs in CI
|
||||
- **Unit tests** — 330+ Rust tests
|
||||
- **Integration tests** — proxy, sync e2e
|
||||
- **Biome**: JS/TS linting and formatting
|
||||
- **Clippy + rustfmt**: Rust linting and formatting
|
||||
- **typos**: Spellcheck (allowlist in `_typos.toml`)
|
||||
- **CodeQL**: Security analysis (JS, Actions, Rust), runs in CI
|
||||
- **Unit tests**: 330+ Rust tests
|
||||
- **Integration tests**: proxy, sync e2e
|
||||
|
||||
### Running CodeQL locally
|
||||
|
||||
@@ -73,7 +73,7 @@ codeql database analyze /tmp/codeql-rust --format=sarifv2.1.0 --output=/tmp/rust
|
||||
|
||||
## Key Rules
|
||||
|
||||
- **Translations**: Any UI text changes must be reflected in all 7 locale files (`src/i18n/locales/`)
|
||||
- **Translations**: Any UI text changes must be reflected in all 9 locale files (`src/i18n/locales/`)
|
||||
- **Tauri commands**: If you modify Tauri commands, the `test_no_unused_tauri_commands` test will catch unused ones
|
||||
- **No hardcoded colors**: Use theme CSS variables (see `src/lib/themes.ts`), never Tailwind color classes like `text-red-500`
|
||||
- **No lock file changes**: Don't update `pnpm-lock.yaml` or `Cargo.lock` unless updating dependencies is the purpose of the PR
|
||||
@@ -88,10 +88,10 @@ codeql database analyze /tmp/codeql-rust --format=sarifv2.1.0 --output=/tmp/rust
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Frontend**: Next.js (React) — `src/`
|
||||
- **Backend**: Tauri (Rust) — `src-tauri/src/`
|
||||
- **Proxy Worker**: Detached process for proxy tunneling — `src-tauri/src/bin/proxy_server.rs`
|
||||
- **Sync**: Cloud sync via S3-compatible storage — `src-tauri/src/sync/`, `donut-sync/`
|
||||
- **Frontend**: Next.js (React), `src/`
|
||||
- **Backend**: Tauri (Rust), `src-tauri/src/`
|
||||
- **Proxy Worker**: Detached process for proxy tunneling, `src-tauri/src/bin/proxy_server.rs`
|
||||
- **Sync**: Cloud sync via S3-compatible storage, `src-tauri/src/sync/`, `donut-sync/`
|
||||
- **Browsers**: Camoufox (Firefox-based) and Wayfern (Chromium-based)
|
||||
|
||||
## Getting Help
|
||||
|
||||
@@ -25,19 +25,19 @@
|
||||
|
||||
## Features
|
||||
|
||||
- **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data
|
||||
- **Chromium & Firefox engines** — Chromium powered by [Wayfern](https://wayfern.com), Firefox powered by [Camoufox](https://camoufox.com), both with advanced fingerprint spoofing
|
||||
- **Unlimited browser profiles**: each fully isolated with its own fingerprint, cookies, extensions, and data
|
||||
- **Anti-detect Chromium engine**: powered by [Wayfern](https://wayfern.com), which is privacy-focused Chromium fork that comes with advanced fingerprint spoofing which naturally hides information in a way that is not detected by Cloudflare, reCaptcha v3, and other browser fingerprinting and anti-bot services.
|
||||
- **DNS AdBlocker** - block ads, trackers, and other unwanted content with per-profile DNS blocking
|
||||
- **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
|
||||
- **VPN support** — WireGuard configs per profile
|
||||
- **Local API & MCP** — REST API and [Model Context Protocol](https://modelcontextprotocol.io) server for integration with Claude, automation tools, and custom workflows
|
||||
- **Profile groups** — organize profiles and apply bulk settings
|
||||
- **Import profiles** — migrate from Chrome, Firefox, Edge, Brave, or other Chromium browsers
|
||||
- **Cookie & extension management** — import/export cookies, manage extensions per profile
|
||||
- **Default browser** — set Donut as your default browser and choose which profile opens each link
|
||||
- **Cloud sync** — sync profiles, proxies, and groups across devices (self-hostable)
|
||||
- **E2E encryption** — optional end-to-end encrypted sync with a password only you know
|
||||
- **Zero telemetry** — no tracking or device fingerprinting
|
||||
- **Proxy support**: HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
|
||||
- **VPN support**: WireGuard configs per profile
|
||||
- **Local API & MCP**: REST API and [Model Context Protocol](https://modelcontextprotocol.io) server for integration with Claude, automation tools, and custom workflows
|
||||
- **Profile groups**: organize profiles and apply bulk settings
|
||||
- **Import profiles**: migrate from Chrome, Firefox, Edge, Brave, or other Chromium browsers
|
||||
- **Cookie & extension management**: import/export cookies, manage extensions per profile
|
||||
- **Default browser**: set Donut as your default browser and choose which profile opens each link
|
||||
- **Cloud sync**: sync profiles, proxies, and groups across devices (self-hostable)
|
||||
- **E2E encryption**: optional end-to-end encrypted sync with a password only you know
|
||||
- **Zero telemetry**: no tracking or device fingerprinting
|
||||
|
||||
## Install
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_x64.dmg) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
@@ -56,15 +56,15 @@ brew install --cask donut
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_x64-portable.zip)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut-0.24.4-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut-0.24.4-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_aarch64.AppImage) |
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut-0.27.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut-0.27.0-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
@@ -94,7 +94,7 @@ nix run github:zhom/donutbrowser#release-start
|
||||
|
||||
## Self-Hosting Sync
|
||||
|
||||
Donut Browser supports syncing profiles, proxies, and groups across devices via a self-hosted sync server. See the [Self-Hosting Guide](docs/self-hosting-donut-sync.md) for Docker-based setup instructions.
|
||||
Donut Browser supports syncing profiles, proxies, and groups across devices via a self-hosted sync server, which makes sync completely free. See the [Self-Hosting Donut Sync guide](https://donutbrowser.com/docs/self-hosting) for Docker-based setup instructions.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -149,6 +149,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
<sub><b>yb403</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/huy97">
|
||||
<img src="https://avatars.githubusercontent.com/u/30153437?v=4" width="100;" alt="huy97"/>
|
||||
<br />
|
||||
<sub><b>Huy Le</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/drunkod">
|
||||
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="drunkod"/>
|
||||
@@ -156,6 +163,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
<sub><b>drunkod</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/JorySeverijnse">
|
||||
<img src="https://avatars.githubusercontent.com/u/117462355?v=4" width="100;" alt="JorySeverijnse"/>
|
||||
@@ -163,8 +172,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
<sub><b>Jory Severijnse</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/ThiagoMafra-Integrare">
|
||||
<img src="https://avatars.githubusercontent.com/u/222241596?v=4" width="100;" alt="ThiagoMafra-Integrare"/>
|
||||
@@ -173,10 +180,10 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/huy97">
|
||||
<img src="https://avatars.githubusercontent.com/u/30153437?v=4" width="100;" alt="huy97"/>
|
||||
<a href="https://github.com/liasica">
|
||||
<img src="https://avatars.githubusercontent.com/u/671431?v=4" width="100;" alt="liasica"/>
|
||||
<br />
|
||||
<sub><b>Huy Le</b></sub>
|
||||
<sub><b>liasica</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
# Self-Hosting Donut Sync
|
||||
|
||||
Donut Sync is the synchronization server for Donut Browser. It allows you to sync your profiles, proxies, and groups across multiple devices. This guide covers how to self-host it using Docker.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
- An S3-compatible object storage (MinIO included by default, or use AWS S3, Cloudflare R2, etc.)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Create a `docker-compose.yml`
|
||||
|
||||
```yaml
|
||||
services:
|
||||
donut-sync:
|
||||
image: donutbrowser/donut-sync:latest
|
||||
ports:
|
||||
- "3929:3929"
|
||||
environment:
|
||||
- SYNC_TOKEN=your-secret-token-here
|
||||
- PORT=3929
|
||||
- S3_ENDPOINT=http://minio:9000
|
||||
- S3_REGION=us-east-1
|
||||
- S3_ACCESS_KEY_ID=minioadmin
|
||||
- S3_SECRET_ACCESS_KEY=minioadmin
|
||||
- S3_BUCKET=donut-sync
|
||||
- S3_FORCE_PATH_STYLE=true
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
command: server /data --console-address ":9001"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
|
||||
volumes:
|
||||
minio_data:
|
||||
```
|
||||
|
||||
### 2. Start the services
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 3. Verify the server is running
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:3929/health
|
||||
# Expected: {"status":"ok"}
|
||||
|
||||
# Readiness check (verifies S3 connectivity)
|
||||
curl http://localhost:3929/readyz
|
||||
# Expected: {"status":"ready","s3":true}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `SYNC_TOKEN` | Yes | - | Bearer token used to authenticate requests from Donut Browser clients |
|
||||
| `PORT` | No | `3929` | Port the sync server listens on |
|
||||
| `S3_ENDPOINT` | No | - | S3-compatible endpoint URL (e.g., `http://minio:9000` or `https://s3.amazonaws.com`) |
|
||||
| `S3_REGION` | No | `us-east-1` | S3 region |
|
||||
| `S3_ACCESS_KEY_ID` | Yes | - | S3 access key |
|
||||
| `S3_SECRET_ACCESS_KEY` | Yes | - | S3 secret key |
|
||||
| `S3_BUCKET` | No | `donut-sync` | S3 bucket name for storing sync data |
|
||||
| `S3_FORCE_PATH_STYLE` | No | `false` | Set to `true` for MinIO and other S3-compatible services that use path-style URLs |
|
||||
|
||||
## Using External S3 Storage
|
||||
|
||||
Instead of running MinIO, you can use any S3-compatible storage service. Remove the `minio` service from `docker-compose.yml` and update the environment variables:
|
||||
|
||||
### AWS S3
|
||||
|
||||
```yaml
|
||||
services:
|
||||
donut-sync:
|
||||
image: donutbrowser/donut-sync:latest
|
||||
ports:
|
||||
- "3929:3929"
|
||||
environment:
|
||||
- SYNC_TOKEN=your-secret-token-here
|
||||
- S3_REGION=us-east-1
|
||||
- S3_ACCESS_KEY_ID=your-aws-access-key
|
||||
- S3_SECRET_ACCESS_KEY=your-aws-secret-key
|
||||
- S3_BUCKET=your-bucket-name
|
||||
```
|
||||
|
||||
### Cloudflare R2
|
||||
|
||||
```yaml
|
||||
services:
|
||||
donut-sync:
|
||||
image: donutbrowser/donut-sync:latest
|
||||
ports:
|
||||
- "3929:3929"
|
||||
environment:
|
||||
- SYNC_TOKEN=your-secret-token-here
|
||||
- S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
|
||||
- S3_REGION=auto
|
||||
- S3_ACCESS_KEY_ID=your-r2-access-key
|
||||
- S3_SECRET_ACCESS_KEY=your-r2-secret-key
|
||||
- S3_BUCKET=your-bucket-name
|
||||
- S3_FORCE_PATH_STYLE=true
|
||||
```
|
||||
|
||||
### Other S3-Compatible Services
|
||||
|
||||
Any service that implements the S3 API (e.g., Backblaze B2, DigitalOcean Spaces, Wasabi) can be used. Set `S3_ENDPOINT` to the service's endpoint URL and `S3_FORCE_PATH_STYLE=true` if required by the provider.
|
||||
|
||||
## Configuring the Donut Browser Client
|
||||
|
||||
1. Open Donut Browser
|
||||
2. Click the sync icon in the header to open the Sync Configuration dialog
|
||||
3. Enter the **Server URL** (e.g., `http://your-server:3929`)
|
||||
4. Enter the **Sync Token** (the value you set for `SYNC_TOKEN`)
|
||||
5. Click **Save**
|
||||
|
||||
Once configured, you can enable sync on individual profiles, proxies, and groups.
|
||||
|
||||
## Health Check Endpoints
|
||||
|
||||
| Endpoint | Description |
|
||||
|---|---|
|
||||
| `GET /health` | Basic health check. Returns `{"status":"ok"}` if the server is running. |
|
||||
| `GET /readyz` | Readiness check. Verifies S3 connectivity. Returns `{"status":"ready","s3":true}` or HTTP 503 if S3 is unreachable. |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Use a strong `SYNC_TOKEN`**: Generate a random token (e.g., `openssl rand -hex 32`) and keep it secret.
|
||||
- **HTTPS**: In production, place a reverse proxy (e.g., Nginx, Caddy, Traefik) in front of Donut Sync to terminate TLS. The sync token is sent as a Bearer token in the `Authorization` header and should not be transmitted over plain HTTP.
|
||||
- **Network isolation**: If running on a VPS, consider restricting access to the sync port using firewall rules or binding only to localhost behind a reverse proxy.
|
||||
- **S3 credentials**: Use dedicated IAM credentials with minimal permissions (read/write to the sync bucket only).
|
||||
|
||||
### Example: Caddy Reverse Proxy
|
||||
|
||||
```
|
||||
sync.yourdomain.com {
|
||||
reverse_proxy localhost:3929
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Nginx Reverse Proxy
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name sync.yourdomain.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3929;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
+10
-10
@@ -18,30 +18,30 @@
|
||||
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1045.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
||||
"@nestjs/common": "^11.1.19",
|
||||
"@aws-sdk/client-s3": "^3.1073.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1073.0",
|
||||
"@nestjs/common": "^11.1.27",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.19",
|
||||
"@nestjs/platform-express": "^11.1.19",
|
||||
"@nestjs/core": "^11.1.27",
|
||||
"@nestjs/platform-express": "^11.1.27",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.21",
|
||||
"@nestjs/cli": "^11.0.23",
|
||||
"@nestjs/schematics": "^11.1.0",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@nestjs/testing": "^11.1.27",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"jest": "^30.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.9",
|
||||
"ts-loader": "^9.5.7",
|
||||
"ts-jest": "^29.4.11",
|
||||
"ts-loader": "^9.6.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^6.0.3"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
@@ -10,6 +11,13 @@ import type { Request } from "express";
|
||||
import * as jwt from "jsonwebtoken";
|
||||
import type { UserContext } from "./user-context.interface.js";
|
||||
|
||||
/** Constant-time string compare; false on length mismatch (no early return). */
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const ab = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(AuthGuard.name);
|
||||
@@ -37,7 +45,7 @@ export class AuthGuard implements CanActivate {
|
||||
|
||||
// Try SYNC_TOKEN first (self-hosted mode)
|
||||
const expectedToken = this.configService.get<string>("SYNC_TOKEN");
|
||||
if (expectedToken && token === expectedToken) {
|
||||
if (expectedToken && safeEqual(token, expectedToken)) {
|
||||
(request as unknown as Record<string, unknown>).user = {
|
||||
mode: "self-hosted",
|
||||
prefix: "",
|
||||
@@ -55,10 +63,29 @@ export class AuthGuard implements CanActivate {
|
||||
algorithms: ["RS256"],
|
||||
}) as jwt.JwtPayload;
|
||||
|
||||
// Validate the scope claims' SHAPE before trusting them as S3 key
|
||||
// prefixes. An empty/over-broad prefix would make validateKeyAccess
|
||||
// (`key.startsWith(prefix)`) authorize the entire bucket, so a signer
|
||||
// bug or permissive claim must not silently widen scope.
|
||||
const prefix = decoded.prefix || `users/${decoded.sub}/`;
|
||||
if (typeof prefix !== "string" || !/^users\/[^/]+\/$/.test(prefix)) {
|
||||
throw new Error(`Invalid prefix claim: ${String(decoded.prefix)}`);
|
||||
}
|
||||
const teamPrefix =
|
||||
decoded.teamPrefix === undefined || decoded.teamPrefix === null
|
||||
? null
|
||||
: decoded.teamPrefix;
|
||||
if (
|
||||
teamPrefix !== null &&
|
||||
!/^teams\/[^/]+\/$/.test(String(teamPrefix))
|
||||
) {
|
||||
throw new Error(`Invalid teamPrefix claim: ${String(teamPrefix)}`);
|
||||
}
|
||||
|
||||
(request as unknown as Record<string, unknown>).user = {
|
||||
mode: "cloud",
|
||||
prefix: decoded.prefix || `users/${decoded.sub}/`,
|
||||
teamPrefix: decoded.teamPrefix || null,
|
||||
prefix,
|
||||
teamPrefix,
|
||||
profileLimit: decoded.profileLimit || 0,
|
||||
teamProfileLimit: decoded.teamProfileLimit || 0,
|
||||
} satisfies UserContext;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@@ -9,6 +10,13 @@ import {
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { SyncService } from "./sync.service.js";
|
||||
|
||||
/** Constant-time string compare; false on length mismatch. */
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const ab = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
@Controller("v1/internal")
|
||||
export class InternalController {
|
||||
private readonly internalKey: string | undefined;
|
||||
@@ -26,7 +34,7 @@ export class InternalController {
|
||||
@Headers("x-internal-key") key: string,
|
||||
@Body() body: { userId: string; maxProfiles: number },
|
||||
) {
|
||||
if (!this.internalKey || key !== this.internalKey) {
|
||||
if (!this.internalKey || !key || !safeEqual(key, this.internalKey)) {
|
||||
throw new UnauthorizedException("Invalid internal key");
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,29 @@ import type {
|
||||
*/
|
||||
const MANIFEST_KEY = ".donut-sync-manifest";
|
||||
|
||||
/** Max presigned-URL lifetime. The client requests ~1h; never mint a URL that
|
||||
* outlives this, regardless of a (possibly hostile) client-supplied expiresIn. */
|
||||
const MAX_PRESIGN_EXPIRES_IN = 3600;
|
||||
|
||||
/** Clamp a client-supplied expiresIn to a sane positive range. */
|
||||
function clampExpiresIn(requested: number | undefined): number {
|
||||
const v = typeof requested === "number" && requested > 0 ? requested : 3600;
|
||||
return Math.min(v, MAX_PRESIGN_EXPIRES_IN);
|
||||
}
|
||||
|
||||
/** Only this metadata key is meaningful to sync (LWW conflict resolution).
|
||||
* Whitelisting prevents a client from signing arbitrary x-amz-meta-* values. */
|
||||
function sanitizeMetadata(
|
||||
metadata: Record<string, string> | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
if (!metadata) return undefined;
|
||||
const out: Record<string, string> = {};
|
||||
if (typeof metadata["updated-at"] === "string") {
|
||||
out["updated-at"] = metadata["updated-at"];
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SyncService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SyncService.name);
|
||||
@@ -286,16 +309,19 @@ export class SyncService implements OnModuleInit {
|
||||
await this.checkProfileLimit(ctx);
|
||||
}
|
||||
|
||||
const expiresIn = dto.expiresIn || 3600;
|
||||
const expiresIn = clampExpiresIn(dto.expiresIn);
|
||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||
|
||||
// Whitelist metadata to the single key sync relies on, so a client can't
|
||||
// sign arbitrary x-amz-meta-* values into its objects.
|
||||
const metadata = sanitizeMetadata(dto.metadata);
|
||||
const command = new PutCmd({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
ContentType: dto.contentType || "application/octet-stream",
|
||||
// Signed into the presigned URL as `x-amz-meta-*`. The client must send
|
||||
// exactly these headers on the PUT, so we echo them in the response.
|
||||
Metadata: dto.metadata,
|
||||
Metadata: metadata,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
|
||||
@@ -313,6 +339,9 @@ export class SyncService implements OnModuleInit {
|
||||
return {
|
||||
url,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
// Echo the metadata we actually signed so the client sends matching
|
||||
// x-amz-meta-* headers on the PUT (S3 rejects unsigned ones).
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -323,7 +352,7 @@ export class SyncService implements OnModuleInit {
|
||||
const key = this.scopeKey(ctx, dto.key);
|
||||
this.validateKeyAccess(ctx, key);
|
||||
|
||||
const expiresIn = dto.expiresIn || 3600;
|
||||
const expiresIn = clampExpiresIn(dto.expiresIn);
|
||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
@@ -438,7 +467,7 @@ export class SyncService implements OnModuleInit {
|
||||
await this.checkProfileLimit(ctx);
|
||||
}
|
||||
|
||||
const expiresIn = dto.expiresIn || 3600;
|
||||
const expiresIn = clampExpiresIn(dto.expiresIn);
|
||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||
|
||||
const items = await Promise.all(
|
||||
@@ -491,7 +520,7 @@ export class SyncService implements OnModuleInit {
|
||||
dto: PresignDownloadBatchRequestDto,
|
||||
ctx: UserContext,
|
||||
): Promise<PresignDownloadBatchResponseDto> {
|
||||
const expiresIn = dto.expiresIn || 3600;
|
||||
const expiresIn = clampExpiresIn(dto.expiresIn);
|
||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||
|
||||
const items = await Promise.all(
|
||||
|
||||
@@ -96,17 +96,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.24.4";
|
||||
releaseVersion = "0.27.0";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_amd64.AppImage";
|
||||
hash = "sha256-YNXPed96GmuMhJVERxa2gYtiaQoMfdB0az5O5J0b/No=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_amd64.AppImage";
|
||||
hash = "sha256-b9jY+SPw+5UvvTKgXmvxLJjIbrLW6kHTVeZywJA6DFE=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.4/Donut_0.24.4_aarch64.AppImage";
|
||||
hash = "sha256-kdEzMO53bCUH7E8GPDewnIDLRIO5pWlO8B4TdpLAQIg=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_aarch64.AppImage";
|
||||
hash = "sha256-UyK3p88kx3JkJmQ9Jv1hQGmfLbG1YZDuF2pZ1h529sQ=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
+32
-32
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.25.0",
|
||||
"version": "0.27.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 12341",
|
||||
@@ -32,22 +32,22 @@
|
||||
"precargo": "pnpm copy-proxy-binary"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-portal": "^1.1.10",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-checkbox": "^1.3.5",
|
||||
"@radix-ui/react-dialog": "^1.1.17",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.18",
|
||||
"@radix-ui/react-label": "^2.1.10",
|
||||
"@radix-ui/react-popover": "^1.1.17",
|
||||
"@radix-ui/react-portal": "^1.1.12",
|
||||
"@radix-ui/react-progress": "^1.1.10",
|
||||
"@radix-ui/react-radio-group": "^1.4.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.12",
|
||||
"@radix-ui/react-select": "^2.3.1",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-tabs": "^1.1.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.10",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@tauri-apps/api": "~2.11.0",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
"@tauri-apps/api": "~2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.9",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
@@ -61,17 +61,17 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"color": "^5.0.3",
|
||||
"flag-icons": "^7.5.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"i18next": "^26.1.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"motion": "^12.38.0",
|
||||
"next": "^16.2.6",
|
||||
"framer-motion": "^12.40.0",
|
||||
"i18next": "^26.3.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
"motion": "^12.40.0",
|
||||
"next": "^16.2.9",
|
||||
"next-themes": "^0.4.6",
|
||||
"onborda": "^1.2.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.7",
|
||||
"radix-ui": "^1.6.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-icons": "^5.6.0",
|
||||
"recharts": "3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -79,17 +79,17 @@
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.15",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@tauri-apps/cli": "~2.11.1",
|
||||
"@biomejs/biome": "2.5.0",
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"@tauri-apps/cli": "~2.11.3",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/color": "^4.2.1",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.4",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"lint-staged": "^17.0.8",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"ts-unused-exports": "^11.0.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.3"
|
||||
|
||||
Generated
+2577
-2927
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,10 @@ overrides:
|
||||
fast-xml-builder@<1.2.0: '>=1.2.0'
|
||||
qs@>=6.11.1 <6.15.2: '>=6.15.2'
|
||||
js-cookie@<3.0.7: '>=3.0.7'
|
||||
multer@>=2.0.0 <2.2.0: '>=2.2.0'
|
||||
form-data@>=4.0.0 <4.0.6: '>=4.0.6'
|
||||
js-yaml@>=4.0.0 <4.2.0: '>=4.2.0 <5'
|
||||
'@babel/core@<7.29.6': '>=7.29.6 <8'
|
||||
|
||||
allowBuilds:
|
||||
'@nestjs/core': true
|
||||
|
||||
@@ -113,8 +113,11 @@ for arch in amd64 arm64; do
|
||||
BINARY_DIR="$DEB_DIR/dists/stable/main/binary-${arch}"
|
||||
|
||||
# dpkg-scanpackages needs to run from the repo root
|
||||
# and needs paths relative to that root
|
||||
(cd "$DEB_DIR" && dpkg-scanpackages --arch "$arch" pool/main) \
|
||||
# and needs paths relative to that root.
|
||||
# -m / --multiversion keeps every version present in the pool in the index
|
||||
# (without it only the newest is listed, making older releases uninstallable
|
||||
# via apt — createrepo_c already keeps all versions for the RPM repo).
|
||||
(cd "$DEB_DIR" && dpkg-scanpackages -m --arch "$arch" pool/main) \
|
||||
> "$BINARY_DIR/Packages"
|
||||
|
||||
gzip -9c "$BINARY_DIR/Packages" > "$BINARY_DIR/Packages.gz"
|
||||
|
||||
@@ -44,7 +44,17 @@ if (!cmd) {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const child = spawn(cmd, args, { stdio: "inherit", shell: false });
|
||||
// On Windows, npm-installed bins (e.g. `tauri`) are `.cmd` shims that cannot be
|
||||
// launched with `shell: false` — Node refuses to exec a batch file directly and
|
||||
// the spawn fails with ENOENT/EINVAL. Run through the shell on Windows (cmd.exe
|
||||
// resolves `tauri.cmd`); macOS/Linux keep `shell: false`, where the bin is a
|
||||
// directly-executable script. Under the Windows shell, quote args containing
|
||||
// whitespace so paths with spaces aren't split into multiple arguments.
|
||||
const isWindows = process.platform === "win32";
|
||||
const spawnArgs = isWindows
|
||||
? args.map((a) => (/\s/.test(a) ? `"${a}"` : a))
|
||||
: args;
|
||||
const child = spawn(cmd, spawnArgs, { stdio: "inherit", shell: isWindows });
|
||||
child.on("error", (err) => {
|
||||
console.error(`Failed to spawn ${cmd}:`, err.message);
|
||||
process.exit(1);
|
||||
|
||||
Generated
+320
-463
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.25.0"
|
||||
version = "0.27.1"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -41,6 +41,7 @@ tauri-plugin-dialog = "2"
|
||||
tauri-plugin-macos-permissions = "2"
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
@@ -72,7 +73,7 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
axum = { version = "0.8.9", features = ["ws"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
tower-http = { version = "0.7", features = ["cors"] }
|
||||
rand = "0.10.1"
|
||||
utoipa = { version = "5", features = ["axum_extras", "chrono"] }
|
||||
utoipa-axum = "0.2"
|
||||
@@ -81,6 +82,7 @@ aes-gcm = "0.10"
|
||||
aes = "0.9"
|
||||
cbc = "0.2"
|
||||
ring = "0.17"
|
||||
subtle = "2"
|
||||
sha2 = "0.11"
|
||||
shadowsocks = { version = "1.24", default-features = false, features = ["aead-cipher"] }
|
||||
hyper = { version = "1.10", features = ["full"] }
|
||||
@@ -143,7 +145,7 @@ hyper = { version = "1.10", features = ["full"] }
|
||||
hyper-util = { version = "0.1", features = ["full"] }
|
||||
http-body-util = "0.1"
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||
tower-http = { version = "0.7", features = ["fs", "trace"] }
|
||||
futures-util = "0.3"
|
||||
serial_test = "3"
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
+418
-41
@@ -57,14 +57,29 @@ pub struct ApiProfileResponse {
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateProfileRequest {
|
||||
pub name: String,
|
||||
/// Browser engine. Must be `"wayfern"` (anti-detect Chromium) or `"camoufox"`
|
||||
/// (anti-detect Firefox). Any other value (e.g. `"chromium"`) is rejected with
|
||||
/// 400.
|
||||
pub browser: String,
|
||||
pub version: String,
|
||||
/// Optional. Omit (or pass `"latest"`) to use the newest already-downloaded
|
||||
/// version of the chosen browser. A concrete version must already be
|
||||
/// downloaded; the create path does not fetch new versions.
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
pub proxy_id: Option<String>,
|
||||
pub vpn_id: Option<String>,
|
||||
pub launch_hook: Option<String>,
|
||||
pub release_type: Option<String>,
|
||||
/// Camoufox fingerprint/config. Send only when `browser` is `"camoufox"`.
|
||||
/// Omit it, or pass an empty object `{}`, to have a fresh fingerprint
|
||||
/// generated automatically at creation. Provide a `fingerprint` field to
|
||||
/// pin a specific one.
|
||||
#[schema(value_type = Object)]
|
||||
pub camoufox_config: Option<serde_json::Value>,
|
||||
/// Wayfern fingerprint/config. Send only when `browser` is `"wayfern"`.
|
||||
/// Omit it, or pass an empty object `{}`, to have a fresh fingerprint
|
||||
/// generated automatically at creation. Provide a `fingerprint` field to
|
||||
/// pin a specific one.
|
||||
#[schema(value_type = Object)]
|
||||
pub wayfern_config: Option<serde_json::Value>,
|
||||
pub group_id: Option<String>,
|
||||
@@ -74,7 +89,9 @@ pub struct CreateProfileRequest {
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateProfileRequest {
|
||||
pub name: Option<String>,
|
||||
pub browser: Option<String>,
|
||||
// No `browser` field: a profile's engine is fixed at creation (changing it
|
||||
// would invalidate the generated fingerprint and on-disk profile dir).
|
||||
// Accepting it here only to silently ignore it misled API clients.
|
||||
pub version: Option<String>,
|
||||
pub proxy_id: Option<String>,
|
||||
pub vpn_id: Option<String>,
|
||||
@@ -230,6 +247,52 @@ struct ImportCookiesResponse {
|
||||
errors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct BatchRunRequest {
|
||||
/// Profile IDs to launch.
|
||||
profile_ids: Vec<String>,
|
||||
/// Optional URL to open in every launched profile.
|
||||
url: Option<String>,
|
||||
/// Launch headless. Defaults to false.
|
||||
headless: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct BatchRunResult {
|
||||
profile_id: String,
|
||||
/// Whether this profile launched successfully.
|
||||
ok: bool,
|
||||
/// Remote debugging port if launched, otherwise null.
|
||||
remote_debugging_port: Option<u16>,
|
||||
/// Failure reason if not launched, otherwise null.
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct BatchRunResponse {
|
||||
results: Vec<BatchRunResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct BatchStopRequest {
|
||||
/// Profile IDs to stop.
|
||||
profile_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct BatchStopResult {
|
||||
profile_id: String,
|
||||
/// Whether this profile was stopped successfully.
|
||||
ok: bool,
|
||||
/// Failure reason if not stopped, otherwise null.
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
struct BatchStopResponse {
|
||||
results: Vec<BatchStopResult>,
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
@@ -241,6 +304,8 @@ struct ImportCookiesResponse {
|
||||
run_profile,
|
||||
open_url_in_profile,
|
||||
kill_profile,
|
||||
batch_run_profiles,
|
||||
batch_stop_profiles,
|
||||
import_profile_cookies,
|
||||
get_groups,
|
||||
get_group,
|
||||
@@ -283,6 +348,12 @@ struct ImportCookiesResponse {
|
||||
DownloadBrowserResponse,
|
||||
RunProfileResponse,
|
||||
RunProfileRequest,
|
||||
BatchRunRequest,
|
||||
BatchRunResult,
|
||||
BatchRunResponse,
|
||||
BatchStopRequest,
|
||||
BatchStopResult,
|
||||
BatchStopResponse,
|
||||
OpenUrlRequest,
|
||||
ImportCookiesRequest,
|
||||
ImportCookiesResponse,
|
||||
@@ -382,6 +453,8 @@ impl ApiServer {
|
||||
.routes(routes!(run_profile))
|
||||
.routes(routes!(open_url_in_profile))
|
||||
.routes(routes!(kill_profile))
|
||||
.routes(routes!(batch_run_profiles))
|
||||
.routes(routes!(batch_stop_profiles))
|
||||
.routes(routes!(import_profile_cookies))
|
||||
.routes(routes!(get_groups, create_group))
|
||||
.routes(routes!(get_group, update_group, delete_group))
|
||||
@@ -405,6 +478,9 @@ impl ApiServer {
|
||||
let api = ApiDoc::openapi();
|
||||
|
||||
let v1_routes = v1_routes
|
||||
// Inert chokepoint (innermost → runs after auth) for the future per-hour
|
||||
// automation request limit. See rate_limit_middleware.
|
||||
.layer(middleware::from_fn(rate_limit_middleware))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
auth_middleware,
|
||||
@@ -508,8 +584,14 @@ async fn auth_middleware(
|
||||
}
|
||||
};
|
||||
|
||||
// Compare tokens
|
||||
if token != stored_token {
|
||||
// Constant-time comparison so the auth check doesn't leak the shared-prefix
|
||||
// length via timing. `ConstantTimeEq` on equal-length byte slices; differing
|
||||
// lengths simply compare unequal.
|
||||
use subtle::ConstantTimeEq;
|
||||
let token_bytes = token.as_bytes();
|
||||
let stored_bytes = stored_token.as_bytes();
|
||||
let matches = token_bytes.len() == stored_bytes.len() && token_bytes.ct_eq(stored_bytes).into();
|
||||
if !matches {
|
||||
log::warn!("[api] Rejected {path}: token mismatch");
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
@@ -550,6 +632,20 @@ async fn request_logging_middleware(request: axum::extract::Request, next: Next)
|
||||
response
|
||||
}
|
||||
|
||||
/// Chokepoint for the future per-hour automation request limit. The limit
|
||||
/// (`requests_per_hour`, default 100) is already plumbed through entitlements;
|
||||
/// this middleware is intentionally inert today — it resolves the limit but
|
||||
/// never blocks. To enforce, count authenticated requests per rolling hour and
|
||||
/// return `StatusCode::TOO_MANY_REQUESTS` once the limit (when > 0) is exceeded.
|
||||
async fn rate_limit_middleware(
|
||||
request: axum::extract::Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let _requests_per_hour = crate::cloud_auth::CLOUD_AUTH.requests_per_hour().await;
|
||||
// TODO(rate-limit): enforce `_requests_per_hour` for automation routes.
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
// Global API server instance
|
||||
lazy_static! {
|
||||
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
|
||||
@@ -586,22 +682,12 @@ pub async fn get_api_server_status() -> Result<Option<u16>, String> {
|
||||
Ok(server_guard.get_port())
|
||||
}
|
||||
|
||||
/// Serialize a browser config (camoufox/wayfern) to JSON for an API response,
|
||||
/// dropping the `fingerprint` field unless the user has an active paid plan.
|
||||
/// Viewing fingerprints is a paid feature, so free users (and unauthenticated
|
||||
/// API/MCP callers) must never receive it. `is_paid` is resolved once per
|
||||
/// handler via `has_active_paid_subscription()`.
|
||||
fn config_to_api_value<T: serde::Serialize>(
|
||||
config: Option<&T>,
|
||||
is_paid: bool,
|
||||
) -> Option<serde_json::Value> {
|
||||
let mut value = serde_json::to_value(config?).ok()?;
|
||||
if !is_paid {
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.remove("fingerprint");
|
||||
}
|
||||
}
|
||||
Some(value)
|
||||
/// Serialize a browser config (camoufox/wayfern) to JSON for an API response.
|
||||
/// Viewing a profile's fingerprint is available to every API caller; only
|
||||
/// editing it (via `update_profile`) and launching/killing profiles
|
||||
/// programmatically require an active paid plan.
|
||||
fn config_to_api_value<T: serde::Serialize>(config: Option<&T>) -> Option<serde_json::Value> {
|
||||
serde_json::to_value(config?).ok()
|
||||
}
|
||||
|
||||
// API Handlers - Profiles
|
||||
@@ -620,9 +706,6 @@ fn config_to_api_value<T: serde::Serialize>(
|
||||
)]
|
||||
async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let is_paid = crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await;
|
||||
match profile_manager.list_profiles() {
|
||||
Ok(profiles) => {
|
||||
let api_profiles: Vec<ApiProfile> = profiles
|
||||
@@ -637,7 +720,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
|
||||
process_id: profile.process_id,
|
||||
last_launch: profile.last_launch,
|
||||
release_type: profile.release_type.clone(),
|
||||
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
|
||||
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref()),
|
||||
group_id: profile.group_id.clone(),
|
||||
tags: profile.tags.clone(),
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
@@ -677,9 +760,6 @@ async fn get_profile(
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let is_paid = crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await;
|
||||
match profile_manager.list_profiles() {
|
||||
Ok(profiles) => {
|
||||
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
|
||||
@@ -694,7 +774,7 @@ async fn get_profile(
|
||||
process_id: profile.process_id,
|
||||
last_launch: profile.last_launch,
|
||||
release_type: profile.release_type.clone(),
|
||||
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
|
||||
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref()),
|
||||
group_id: profile.group_id.clone(),
|
||||
tags: profile.tags.clone(),
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
@@ -710,14 +790,24 @@ async fn get_profile(
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a profile.
|
||||
///
|
||||
/// - `browser` must be `"wayfern"` or `"camoufox"`; any other value is rejected
|
||||
/// with 400.
|
||||
/// - `version` is optional: omit it or pass `"latest"` to use the newest
|
||||
/// already-downloaded version of that browser. The version must be present
|
||||
/// locally (this endpoint does not download new versions); 400 if none is.
|
||||
/// - Omitting the matching `wayfern_config`/`camoufox_config`, or passing an
|
||||
/// empty object `{}`, generates a fresh fingerprint automatically.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/profiles",
|
||||
request_body = CreateProfileRequest,
|
||||
responses(
|
||||
(status = 200, description = "Profile created successfully", body = ApiProfileResponse),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 400, description = "Invalid browser, or no downloaded version available"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Selected proxy requires payment"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
@@ -728,11 +818,50 @@ async fn get_profile(
|
||||
async fn create_profile(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<CreateProfileRequest>,
|
||||
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
||||
) -> Result<Json<ApiProfileResponse>, (StatusCode, String)> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let is_paid = crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await;
|
||||
|
||||
// Only Wayfern and Camoufox profiles are launchable; the rest of the system
|
||||
// (fingerprint generation, launch, run) supports nothing else. Reject anything
|
||||
// else up front — otherwise the profile is created with no fingerprint and an
|
||||
// unrecognized browser, then crashes with a 500 on /run. Mirrors the MCP
|
||||
// create_profile validation.
|
||||
if request.browser != "wayfern" && request.browser != "camoufox" {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!(
|
||||
"Invalid browser \"{}\". Must be \"wayfern\" (anti-detect Chromium) or \"camoufox\" (anti-detect Firefox).",
|
||||
request.browser
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// Resolve the version. Omitted, empty, or "latest" means "newest version
|
||||
// already downloaded for this browser". The create path generates the
|
||||
// fingerprint by launching that binary, so the version must be present
|
||||
// locally — we don't fetch new versions here. 400 if none is downloaded.
|
||||
let version = match request.version.as_deref() {
|
||||
Some(v) if !v.is_empty() && v != "latest" => v.to_string(),
|
||||
_ => {
|
||||
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
let mut versions = registry.get_downloaded_versions(&request.browser);
|
||||
// browsers is a HashMap, so keys are unordered — sort newest-first by
|
||||
// semver before taking the latest.
|
||||
versions.sort_by(|a, b| crate::api_client::compare_versions(b, a));
|
||||
match versions.into_iter().next() {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!(
|
||||
"No downloaded version of \"{}\" is available. Download the browser in Donut Browser first — this endpoint does not download browsers.",
|
||||
request.browser
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Parse camoufox config if provided
|
||||
let camoufox_config = if let Some(config) = &request.camoufox_config {
|
||||
@@ -754,9 +883,15 @@ async fn create_profile(
|
||||
crate::validate_profile_network(request.proxy_id.as_deref(), request.vpn_id.as_deref()).await
|
||||
{
|
||||
return Err(if err.contains("PROXY_PAYMENT_REQUIRED") {
|
||||
StatusCode::PAYMENT_REQUIRED
|
||||
(
|
||||
StatusCode::PAYMENT_REQUIRED,
|
||||
"The selected proxy requires an active subscription.".to_string(),
|
||||
)
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Profile network validation failed: {err}"),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -766,7 +901,7 @@ async fn create_profile(
|
||||
&state.app_handle,
|
||||
&request.name,
|
||||
&request.browser,
|
||||
&request.version,
|
||||
&version,
|
||||
request.release_type.as_deref().unwrap_or("stable"),
|
||||
request.proxy_id.clone(),
|
||||
request.vpn_id.clone(),
|
||||
@@ -786,7 +921,10 @@ async fn create_profile(
|
||||
.update_profile_tags(&state.app_handle, &profile.name, tags.clone())
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Profile created but failed to apply tags.".to_string(),
|
||||
));
|
||||
}
|
||||
profile.tags = tags.clone();
|
||||
}
|
||||
@@ -809,7 +947,7 @@ async fn create_profile(
|
||||
process_id: profile.process_id,
|
||||
last_launch: profile.last_launch,
|
||||
release_type: profile.release_type,
|
||||
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref(), is_paid),
|
||||
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref()),
|
||||
group_id: profile.group_id,
|
||||
tags: profile.tags,
|
||||
is_running: false,
|
||||
@@ -818,7 +956,10 @@ async fn create_profile(
|
||||
},
|
||||
}))
|
||||
}
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
Err(e) => Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Failed to create profile: {e}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -914,6 +1055,14 @@ async fn update_profile(
|
||||
}
|
||||
|
||||
if let Some(camoufox_config) = request.camoufox_config {
|
||||
// Editing a profile's fingerprint config is part of the cross-OS fingerprint
|
||||
// capability (GUI, API, MCP). Viewing it is free; mutating it is not.
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_cross_os_fingerprints()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
}
|
||||
let config: Result<CamoufoxConfig, _> = serde_json::from_value(camoufox_config);
|
||||
match config {
|
||||
Ok(config) => {
|
||||
@@ -1732,7 +1881,7 @@ async fn run_profile(
|
||||
Json(request): Json<RunProfileRequest>,
|
||||
) -> Result<Json<RunProfileResponse>, StatusCode> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
@@ -1818,7 +1967,7 @@ async fn open_url_in_profile(
|
||||
Json(request): Json<OpenUrlRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
@@ -1844,6 +1993,7 @@ async fn open_url_in_profile(
|
||||
responses(
|
||||
(status = 204, description = "Browser process killed successfully"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan required"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
@@ -1856,6 +2006,15 @@ async fn kill_profile(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
// Programmatically launching and stopping profiles is a paid feature; the
|
||||
// run/open-url handlers gate the same way.
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
}
|
||||
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
@@ -1877,6 +2036,170 @@ async fn kill_profile(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// API Handler - Batch run profiles (paid: browser automation). Mirrors the
|
||||
// single `/run` gate; never breaks the batch on a single profile's failure —
|
||||
// each profile gets its own result entry.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/profiles/batch/run",
|
||||
request_body = BatchRunRequest,
|
||||
responses(
|
||||
(status = 200, description = "Batch launch completed; inspect per-profile results", body = BatchRunResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "profiles"
|
||||
)]
|
||||
async fn batch_run_profiles(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<BatchRunRequest>,
|
||||
) -> Result<Json<BatchRunResponse>, StatusCode> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
}
|
||||
|
||||
let headless = request.headless.unwrap_or(false);
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let mut results = Vec::with_capacity(request.profile_ids.len());
|
||||
for profile_id in &request.profile_ids {
|
||||
let fail = |error: &str| BatchRunResult {
|
||||
profile_id: profile_id.clone(),
|
||||
ok: false,
|
||||
remote_debugging_port: None,
|
||||
error: Some(error.to_string()),
|
||||
};
|
||||
|
||||
let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
|
||||
results.push(fail("profile not found"));
|
||||
continue;
|
||||
};
|
||||
if profile.is_cross_os() {
|
||||
results.push(fail("cross-OS profiles cannot be launched"));
|
||||
continue;
|
||||
}
|
||||
if crate::team_lock::acquire_team_lock_if_needed(profile)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
results.push(fail("profile is locked by another team member"));
|
||||
continue;
|
||||
}
|
||||
|
||||
let port = match tokio::net::TcpListener::bind("127.0.0.1:0").await {
|
||||
Ok(listener) => match listener.local_addr() {
|
||||
Ok(addr) => addr.port(),
|
||||
Err(_) => {
|
||||
results.push(fail("failed to allocate debugging port"));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
results.push(fail("failed to allocate debugging port"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match crate::browser_runner::launch_browser_profile_impl(
|
||||
state.app_handle.clone(),
|
||||
profile.clone(),
|
||||
request.url.clone(),
|
||||
Some(port),
|
||||
headless,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => results.push(BatchRunResult {
|
||||
profile_id: profile_id.clone(),
|
||||
ok: true,
|
||||
remote_debugging_port: Some(port),
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => results.push(fail(&format!("launch failed: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(BatchRunResponse { results }))
|
||||
}
|
||||
|
||||
// API Handler - Batch stop profiles (paid: browser automation).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/profiles/batch/stop",
|
||||
request_body = BatchStopRequest,
|
||||
responses(
|
||||
(status = 200, description = "Batch stop completed; inspect per-profile results", body = BatchStopResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "profiles"
|
||||
)]
|
||||
async fn batch_stop_profiles(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<BatchStopRequest>,
|
||||
) -> Result<Json<BatchStopResponse>, StatusCode> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
}
|
||||
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
|
||||
let mut results = Vec::with_capacity(request.profile_ids.len());
|
||||
for profile_id in &request.profile_ids {
|
||||
let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
|
||||
results.push(BatchStopResult {
|
||||
profile_id: profile_id.clone(),
|
||||
ok: false,
|
||||
error: Some("profile not found".to_string()),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
|
||||
match browser_runner
|
||||
.kill_browser_process(state.app_handle.clone(), profile)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
crate::team_lock::release_team_lock_if_needed(profile).await;
|
||||
results.push(BatchStopResult {
|
||||
profile_id: profile_id.clone(),
|
||||
ok: true,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(e) => results.push(BatchStopResult {
|
||||
profile_id: profile_id.clone(),
|
||||
ok: false,
|
||||
error: Some(format!("stop failed: {e}")),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(BatchStopResponse { results }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/profiles/{id}/cookies/import",
|
||||
@@ -2091,3 +2414,57 @@ async fn refresh_wayfern_token(
|
||||
let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
||||
Ok(Json(WayfernTokenResponse { token }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Removing `browser` from UpdateProfileRequest, and rejecting invalid
|
||||
// `browser` values on create, must NOT make the API reject requests that
|
||||
// carry extra/unknown fields — old clients still send them. serde ignores
|
||||
// unknown fields by default; these tests lock that in so a future
|
||||
// `#[serde(deny_unknown_fields)]` can't silently break compatibility.
|
||||
#[test]
|
||||
fn update_profile_request_ignores_unknown_fields() {
|
||||
// `browser` is no longer a field, plus a wholly unknown field. Both must
|
||||
// be accepted and ignored, not rejected.
|
||||
let json = r#"{"name": "p", "browser": "wayfern", "totally_unknown": 123}"#;
|
||||
let parsed: UpdateProfileRequest =
|
||||
serde_json::from_str(json).expect("unknown fields must be ignored, not rejected");
|
||||
assert_eq!(parsed.name.as_deref(), Some("p"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_profile_request_ignores_unknown_fields() {
|
||||
let json = r#"{"name": "p", "browser": "wayfern", "version": "latest", "future_field": true}"#;
|
||||
let parsed: CreateProfileRequest =
|
||||
serde_json::from_str(json).expect("unknown fields must be ignored, not rejected");
|
||||
assert_eq!(parsed.browser, "wayfern");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_profile_request_allows_omitting_version_and_configs() {
|
||||
// Minimal body: no version, no wayfern_config/camoufox_config. Must
|
||||
// deserialize (version resolves to latest-downloaded at the handler; an
|
||||
// absent config triggers fresh-fingerprint generation).
|
||||
let json = r#"{"name": "p", "browser": "wayfern"}"#;
|
||||
let parsed: CreateProfileRequest =
|
||||
serde_json::from_str(json).expect("version and configs are optional");
|
||||
assert_eq!(parsed.browser, "wayfern");
|
||||
assert!(parsed.version.is_none());
|
||||
assert!(parsed.wayfern_config.is_none());
|
||||
assert!(parsed.camoufox_config.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_profile_browser_validation_matches_supported_engines() {
|
||||
// The handler rejects anything that isn't a launchable engine; this is the
|
||||
// same predicate it uses, kept in lockstep with MCP's create_profile.
|
||||
let is_valid = |b: &str| b == "wayfern" || b == "camoufox";
|
||||
assert!(is_valid("wayfern"));
|
||||
assert!(is_valid("camoufox"));
|
||||
assert!(!is_valid("chromium"));
|
||||
assert!(!is_valid("firefox"));
|
||||
assert!(!is_valid(""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1492,7 +1492,7 @@ impl AppAutoUpdater {
|
||||
|
||||
// Create the restart script content
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
r#"#!/bin/sh
|
||||
# Wait for the current process to exit
|
||||
while kill -0 {} 2>/dev/null; do
|
||||
sleep 0.5
|
||||
@@ -1521,7 +1521,7 @@ rm "{}"
|
||||
.output();
|
||||
|
||||
// Execute the restart script in the background
|
||||
let mut cmd = Command::new("bash");
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg(script_path.to_str().unwrap());
|
||||
|
||||
// Detach the process completely
|
||||
@@ -1668,7 +1668,7 @@ rm "{}"
|
||||
|
||||
// Create the restart script content
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
r#"#!/bin/sh
|
||||
# Wait for the current process to exit
|
||||
while kill -0 {} 2>/dev/null; do
|
||||
sleep 0.5
|
||||
@@ -1697,7 +1697,7 @@ rm "{}"
|
||||
.output();
|
||||
|
||||
// Execute the restart script in the background
|
||||
let mut cmd = Command::new("bash");
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg(script_path.to_str().unwrap());
|
||||
|
||||
// Detach the process completely
|
||||
|
||||
@@ -162,6 +162,11 @@ async fn main() {
|
||||
Arg::new("blocklist-file")
|
||||
.long("blocklist-file")
|
||||
.help("Path to DNS blocklist file (one domain per line)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("local-protocol")
|
||||
.long("local-protocol")
|
||||
.help("Protocol served to the browser: http (default) or socks5"),
|
||||
),
|
||||
)
|
||||
.subcommand(
|
||||
@@ -251,6 +256,7 @@ async fn main() {
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
let blocklist_file = start_matches.get_one::<String>("blocklist-file").cloned();
|
||||
let local_protocol = start_matches.get_one::<String>("local-protocol").cloned();
|
||||
|
||||
match start_proxy_process_with_profile(
|
||||
upstream_url,
|
||||
@@ -258,6 +264,7 @@ async fn main() {
|
||||
profile_id,
|
||||
bypass_rules,
|
||||
blocklist_file,
|
||||
local_protocol,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -261,6 +261,11 @@ impl BrowserRunner {
|
||||
Some(&profile_id_str),
|
||||
profile.proxy_bypass_rules.clone(),
|
||||
blocklist_file,
|
||||
// Camoufox (Firefox 150, and Firefox 135 on the not-yet-updated
|
||||
// Windows build) keeps the local HTTP proxy: Firefox's QUIC stack
|
||||
// bypasses a configured proxy, so QUIC is disabled and HTTP CONNECT
|
||||
// covers everything. SOCKS5 is reserved for Wayfern.
|
||||
"http",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -404,6 +409,10 @@ impl BrowserRunner {
|
||||
log::info!("Updated proxy PID mapping from temp (0) to actual PID: {process_id}");
|
||||
}
|
||||
|
||||
// Persist the real browser PID so the detached proxy worker self-reaps
|
||||
// when this browser dies, even after the GUI exits/restarts.
|
||||
PROXY_MANAGER.set_browser_pid_for_profile(&updated_profile.id.to_string(), process_id);
|
||||
|
||||
// Save the updated profile (includes new fingerprint if randomize is enabled)
|
||||
log::info!(
|
||||
"Saving profile {} with camoufox_config fingerprint length: {}",
|
||||
@@ -527,6 +536,11 @@ impl BrowserRunner {
|
||||
Some(&profile_id_str),
|
||||
profile.proxy_bypass_rules.clone(),
|
||||
blocklist_file,
|
||||
// Wayfern (Chromium) uses a local SOCKS5 proxy so QUIC and WebRTC
|
||||
// UDP can be routed through it (via SOCKS5 UDP ASSOCIATE) without
|
||||
// leaking the real IP, rather than being forced direct as they
|
||||
// would be over an HTTP CONNECT proxy.
|
||||
"socks5",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -535,8 +549,9 @@ impl BrowserRunner {
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
// Format proxy URL for wayfern - always use HTTP for the local proxy
|
||||
let proxy_url = format!("http://{}:{}", local_proxy.host, local_proxy.port);
|
||||
// Format proxy URL for wayfern - use SOCKS5 for the local proxy so
|
||||
// Chromium proxies UDP (QUIC/WebRTC), not just TCP.
|
||||
let proxy_url = format!("socks5://{}:{}", local_proxy.host, local_proxy.port);
|
||||
|
||||
// Set proxy in wayfern config
|
||||
wayfern_config.proxy = Some(proxy_url);
|
||||
@@ -582,6 +597,14 @@ impl BrowserRunner {
|
||||
if wayfern_config.os.is_some() {
|
||||
updated_wayfern_config.os = wayfern_config.os.clone();
|
||||
}
|
||||
// The fresh fingerprint's location matches the current routing; record
|
||||
// its signature so launches keep it in sync with the non-randomize path.
|
||||
updated_wayfern_config.geo_proxy_signature =
|
||||
Some(crate::wayfern_manager::WayfernManager::geo_signature(
|
||||
upstream_proxy.as_ref(),
|
||||
profile.vpn_id.as_deref(),
|
||||
wayfern_config.geoip.as_ref(),
|
||||
));
|
||||
updated_profile.wayfern_config = Some(updated_wayfern_config.clone());
|
||||
|
||||
log::info!(
|
||||
@@ -589,6 +612,62 @@ impl BrowserRunner {
|
||||
profile.name,
|
||||
updated_wayfern_config.fingerprint.as_ref().map(|f| f.len()).unwrap_or(0)
|
||||
);
|
||||
} else {
|
||||
// Safety net: the stored fingerprint's timezone and geolocation were
|
||||
// computed for whatever proxy was set when the fingerprint was
|
||||
// generated. If the profile's proxy or VPN has changed since (the
|
||||
// common case being a user who forgot to set a proxy at creation and
|
||||
// added one afterwards), that location data is stale and the user would
|
||||
// see the wrong timezone on first launch. When the routing signature no
|
||||
// longer matches, refresh just the location fields of the stored
|
||||
// fingerprint through the current proxy. Wayfern only; the randomize
|
||||
// path above already regenerates the whole fingerprint each launch.
|
||||
let current_geo_sig = crate::wayfern_manager::WayfernManager::geo_signature(
|
||||
upstream_proxy.as_ref(),
|
||||
profile.vpn_id.as_deref(),
|
||||
wayfern_config.geoip.as_ref(),
|
||||
);
|
||||
let geo_enabled = !matches!(
|
||||
wayfern_config.geoip.as_ref(),
|
||||
Some(serde_json::Value::Bool(false))
|
||||
);
|
||||
if geo_enabled
|
||||
&& wayfern_config.geo_proxy_signature.as_deref() != Some(current_geo_sig.as_str())
|
||||
{
|
||||
if let Some(stored_fp) = wayfern_config.fingerprint.clone() {
|
||||
log::info!(
|
||||
"Routing changed for Wayfern profile {} since its fingerprint was generated (was {:?}, now {}); refreshing timezone and geolocation",
|
||||
profile.name,
|
||||
wayfern_config.geo_proxy_signature,
|
||||
current_geo_sig
|
||||
);
|
||||
match crate::wayfern_manager::WayfernManager::refresh_fingerprint_geolocation(
|
||||
&stored_fp,
|
||||
wayfern_config.proxy.as_deref(),
|
||||
wayfern_config.geoip.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(refreshed) => {
|
||||
// Use the refreshed fingerprint for this launch...
|
||||
wayfern_config.fingerprint = Some(refreshed.clone());
|
||||
wayfern_config.geo_proxy_signature = Some(current_geo_sig.clone());
|
||||
// ...and persist it so the corrected location sticks and we do
|
||||
// not refresh again on the next launch with the same proxy.
|
||||
let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default();
|
||||
cfg.fingerprint = Some(refreshed);
|
||||
cfg.geo_proxy_signature = Some(current_geo_sig);
|
||||
updated_profile.wayfern_config = Some(cfg);
|
||||
}
|
||||
None => {
|
||||
log::warn!(
|
||||
"Could not refresh geolocation for Wayfern profile {} (proxy unreachable?); launching with existing location and will retry next launch",
|
||||
profile.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create ephemeral dir for ephemeral or password-protected profiles
|
||||
@@ -685,6 +764,10 @@ impl BrowserRunner {
|
||||
log::info!("Updated proxy PID mapping from temp (0) to actual PID: {process_id}");
|
||||
}
|
||||
|
||||
// Persist the real browser PID so the detached proxy worker self-reaps
|
||||
// when this browser dies, even after the GUI exits/restarts.
|
||||
PROXY_MANAGER.set_browser_pid_for_profile(&updated_profile.id.to_string(), process_id);
|
||||
|
||||
// Save the updated profile
|
||||
log::info!(
|
||||
"Saving profile {} with wayfern_config fingerprint length: {}",
|
||||
|
||||
+189
-23
@@ -21,6 +21,76 @@ use crate::sync;
|
||||
pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com";
|
||||
pub const CLOUD_SYNC_URL: &str = "https://sync.donutbrowser.com";
|
||||
|
||||
/// Default per-hour cap on local automation API / MCP requests. Mirrors the
|
||||
/// backend's DEFAULT_REQUESTS_PER_HOUR. Not enforced yet — see the inert
|
||||
/// rate-limit chokepoints in api_server / mcp_server.
|
||||
const DEFAULT_REQUESTS_PER_HOUR: i64 = 100;
|
||||
|
||||
/// Capability + limit set the account is entitled to, derived from its plan.
|
||||
/// Mirrors `apps/backend/src/plans/entitlements.ts`. Features are gated on these
|
||||
/// flags instead of a single "is paid?" boolean, so a plan like the future
|
||||
/// "starter" tier (cross-OS fingerprints + cloud backup, no automation) is just
|
||||
/// data here.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Entitlements {
|
||||
#[serde(default)]
|
||||
pub active: bool,
|
||||
#[serde(rename = "browserAutomation", default)]
|
||||
pub browser_automation: bool,
|
||||
#[serde(rename = "crossOsFingerprints", default)]
|
||||
pub cross_os_fingerprints: bool,
|
||||
#[serde(rename = "cloudBackup", default)]
|
||||
pub cloud_backup: bool,
|
||||
#[serde(rename = "teamCollaboration", default)]
|
||||
pub team_collaboration: bool,
|
||||
#[serde(rename = "profileLimit", default)]
|
||||
pub profile_limit: i64,
|
||||
#[serde(rename = "requestsPerHour", default)]
|
||||
pub requests_per_hour: i64,
|
||||
}
|
||||
|
||||
/// Local fallback mirror of the backend plan -> capability matrix, used only when
|
||||
/// the server hasn't sent an entitlements object (older cached state / backend).
|
||||
fn derive_entitlements(
|
||||
plan: &str,
|
||||
plan_period: Option<&str>,
|
||||
subscription_status: &str,
|
||||
profile_limit: i64,
|
||||
) -> Entitlements {
|
||||
let active =
|
||||
plan != "free" && (subscription_status == "active" || plan_period == Some("lifetime"));
|
||||
if !active {
|
||||
return Entitlements {
|
||||
active: false,
|
||||
browser_automation: false,
|
||||
cross_os_fingerprints: false,
|
||||
cloud_backup: false,
|
||||
team_collaboration: false,
|
||||
profile_limit: 0,
|
||||
requests_per_hour: 0,
|
||||
};
|
||||
}
|
||||
// pro and any unrecognized paid plan -> pro-level (never team).
|
||||
let (browser_automation, cross_os_fingerprints, cloud_backup, team_collaboration) = match plan {
|
||||
"starter" => (false, true, true, false),
|
||||
"team" | "enterprise" => (true, true, true, true),
|
||||
_ => (true, true, true, false),
|
||||
};
|
||||
Entitlements {
|
||||
active,
|
||||
browser_automation,
|
||||
cross_os_fingerprints,
|
||||
cloud_backup,
|
||||
team_collaboration,
|
||||
profile_limit,
|
||||
requests_per_hour: if browser_automation {
|
||||
DEFAULT_REQUESTS_PER_HOUR
|
||||
} else {
|
||||
0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CloudUser {
|
||||
pub id: String,
|
||||
@@ -56,6 +126,26 @@ pub struct CloudUser {
|
||||
pub device_count: Option<i64>,
|
||||
#[serde(rename = "isPrimaryDevice", default)]
|
||||
pub is_primary_device: Option<bool>,
|
||||
/// Capability/limit set derived from the plan by the backend. `default` (None)
|
||||
/// keeps older login/state payloads deserializing; resolve via `entitlements()`.
|
||||
#[serde(default)]
|
||||
pub entitlements: Option<Entitlements>,
|
||||
}
|
||||
|
||||
impl CloudUser {
|
||||
/// Authoritative entitlements: the server-sent set when present, else derived
|
||||
/// locally from the plan fields (keeps older cached state / backends working).
|
||||
pub fn entitlements(&self) -> Entitlements {
|
||||
if let Some(e) = &self.entitlements {
|
||||
return e.clone();
|
||||
}
|
||||
derive_entitlements(
|
||||
&self.plan,
|
||||
self.plan_period.as_deref(),
|
||||
&self.subscription_status,
|
||||
self.profile_limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -658,39 +748,83 @@ impl CloudAuthManager {
|
||||
state.is_some()
|
||||
}
|
||||
|
||||
pub async fn has_active_paid_subscription(&self) -> bool {
|
||||
/// Resolve this session's entitlements (server-sent or locally derived).
|
||||
pub async fn entitlements(&self) -> Option<Entitlements> {
|
||||
let state = self.state.lock().await;
|
||||
match &*state {
|
||||
Some(auth) => {
|
||||
auth.user.plan != "free"
|
||||
&& (auth.user.subscription_status == "active"
|
||||
|| auth.user.plan_period.as_deref() == Some("lifetime"))
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
state.as_ref().map(|auth| auth.user.entitlements())
|
||||
}
|
||||
|
||||
/// Account is in a paid/active state. Used for the "any active plan" gates
|
||||
/// (sync token, wayfern token); per-feature access uses the capability helpers.
|
||||
pub async fn has_active_paid_subscription(&self) -> bool {
|
||||
self.entitlements().await.map(|e| e.active).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Non-async version that uses try_lock, defaults to false if lock can't be acquired.
|
||||
pub fn has_active_paid_subscription_sync(&self) -> bool {
|
||||
match self.state.try_lock() {
|
||||
Ok(state) => match &*state {
|
||||
Some(auth) => {
|
||||
auth.user.plan != "free"
|
||||
&& (auth.user.subscription_status == "active"
|
||||
|| auth.user.plan_period.as_deref() == Some("lifetime"))
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
Ok(state) => state
|
||||
.as_ref()
|
||||
.map(|auth| auth.user.entitlements().active)
|
||||
.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Launch/drive profiles programmatically (local API + MCP automation).
|
||||
pub async fn can_use_browser_automation(&self) -> bool {
|
||||
self
|
||||
.entitlements()
|
||||
.await
|
||||
.map(|e| e.browser_automation)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Edit fingerprints / use a non-native OS fingerprint.
|
||||
pub async fn can_use_cross_os_fingerprints(&self) -> bool {
|
||||
self
|
||||
.entitlements()
|
||||
.await
|
||||
.map(|e| e.cross_os_fingerprints)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Cloud profile sync / backup (async).
|
||||
pub async fn can_use_cloud_backup(&self) -> bool {
|
||||
self
|
||||
.entitlements()
|
||||
.await
|
||||
.map(|e| e.cloud_backup)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Cloud profile sync / backup (non-async, try_lock; false if unavailable).
|
||||
pub fn can_use_cloud_backup_sync(&self) -> bool {
|
||||
match self.state.try_lock() {
|
||||
Ok(state) => state
|
||||
.as_ref()
|
||||
.map(|auth| auth.user.entitlements().cloud_backup)
|
||||
.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-hour cap on automation requests (0 when automation is unavailable).
|
||||
/// Carried for the future local rate limiter; read by the inert chokepoints.
|
||||
pub async fn requests_per_hour(&self) -> i64 {
|
||||
self
|
||||
.entitlements()
|
||||
.await
|
||||
.map(|e| e.requests_per_hour)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub async fn is_fingerprint_os_allowed(&self, fingerprint_os: Option<&str>) -> bool {
|
||||
let host_os = crate::profile::types::get_host_os();
|
||||
match fingerprint_os {
|
||||
None => true,
|
||||
Some(os) if os == host_os => true,
|
||||
Some(_) => self.has_active_paid_subscription().await,
|
||||
Some(_) => self.can_use_cross_os_fingerprints().await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1016,7 +1150,7 @@ impl CloudAuthManager {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let token = self
|
||||
let result = self
|
||||
.api_call_with_retry(|access_token| {
|
||||
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
|
||||
// Bound the request: without a timeout, an unreachable
|
||||
@@ -1050,7 +1184,31 @@ impl CloudAuthManager {
|
||||
Ok(result.token)
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
.await;
|
||||
|
||||
let token = match result {
|
||||
Ok(token) => token,
|
||||
Err(e) => {
|
||||
// The backend returns 403 (ForbiddenException) for paid-feature blocks:
|
||||
// token-reuse throttle, "active subscription required", and the
|
||||
// primary-device restriction (see donutbrowser-infra wayfern.service.ts).
|
||||
// This is distinct from a 401 (dead access token) — the session is still
|
||||
// valid, the user is just temporarily/conditionally not entitled. So we
|
||||
// do NOT invalidate the session. Instead: drop the stale wayfern token so
|
||||
// no browser launches half-authenticated, re-fetch the profile so the
|
||||
// cached plan reflects the backend's real state (it may have changed),
|
||||
// and signal the UI so the user learns why automation stopped working.
|
||||
if e.contains("(403") || e.contains("Forbidden") {
|
||||
log::warn!("Wayfern token blocked by backend (403): {e}");
|
||||
self.clear_wayfern_token().await;
|
||||
if let Err(fetch_err) = self.fetch_profile().await {
|
||||
log::warn!("Profile re-fetch after wayfern block failed: {fetch_err}");
|
||||
}
|
||||
let _ = crate::events::emit_empty("wayfern-paid-blocked");
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let mut wt = self.wayfern_token.lock().await;
|
||||
*wt = Some(token);
|
||||
@@ -1184,7 +1342,7 @@ pub async fn cloud_exchange_device_code(
|
||||
app_handle: tauri::AppHandle,
|
||||
code: String,
|
||||
) -> Result<CloudAuthState, String> {
|
||||
let state = CLOUD_AUTH.exchange_device_code(&code).await?;
|
||||
let mut state = CLOUD_AUTH.exchange_device_code(&code).await?;
|
||||
|
||||
let has_subscription = CLOUD_AUTH.has_active_paid_subscription().await;
|
||||
log::info!(
|
||||
@@ -1219,17 +1377,25 @@ pub async fn cloud_exchange_device_code(
|
||||
let _ = crate::events::emit_empty("cloud-auth-changed");
|
||||
|
||||
let _ = &app_handle;
|
||||
state.user.entitlements = Some(state.user.entitlements());
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_get_user() -> Result<Option<CloudAuthState>, String> {
|
||||
Ok(CLOUD_AUTH.get_user().await)
|
||||
Ok(CLOUD_AUTH.get_user().await.map(|mut state| {
|
||||
// Always hand the frontend a resolved entitlements object so it never has to
|
||||
// derive capabilities itself (covers older cached state with no entitlements).
|
||||
state.user.entitlements = Some(state.user.entitlements());
|
||||
state
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
|
||||
CLOUD_AUTH.fetch_profile().await
|
||||
let mut user = CLOUD_AUTH.fetch_profile().await?;
|
||||
user.entitlements = Some(user.entitlements());
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -85,7 +85,11 @@ impl GroupManager {
|
||||
|
||||
// Check if group with this name already exists
|
||||
if groups_data.groups.iter().any(|g| g.name == name) {
|
||||
return Err(format!("Group with name '{name}' already exists").into());
|
||||
return Err(
|
||||
serde_json::json!({ "code": "GROUP_ALREADY_EXISTS" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let sync_enabled = crate::sync::is_sync_configured();
|
||||
@@ -131,14 +135,18 @@ impl GroupManager {
|
||||
.iter()
|
||||
.any(|g| g.name == name && g.id != id)
|
||||
{
|
||||
return Err(format!("Group with name '{name}' already exists").into());
|
||||
return Err(
|
||||
serde_json::json!({ "code": "GROUP_ALREADY_EXISTS" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let group = groups_data
|
||||
.groups
|
||||
.iter_mut()
|
||||
.find(|g| g.id == id)
|
||||
.ok_or_else(|| format!("Group with id '{id}' not found"))?;
|
||||
.ok_or_else(|| serde_json::json!({ "code": "GROUP_NOT_FOUND" }).to_string())?;
|
||||
|
||||
group.name = name;
|
||||
group.updated_at = Some(crate::proxy_manager::now_secs());
|
||||
@@ -204,7 +212,11 @@ impl GroupManager {
|
||||
let initial_len = groups_data.groups.len();
|
||||
groups_data.groups.retain(|g| g.id != id);
|
||||
if groups_data.groups.len() == initial_len {
|
||||
return Err(format!("Group with id '{id}' not found").into());
|
||||
return Err(
|
||||
serde_json::json!({ "code": "GROUP_NOT_FOUND" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
self.save_groups_data(&groups_data)?;
|
||||
Ok(())
|
||||
@@ -229,7 +241,11 @@ impl GroupManager {
|
||||
groups_data.groups.retain(|g| g.id != id);
|
||||
|
||||
if groups_data.groups.len() == initial_len {
|
||||
return Err(format!("Group with id '{id}' not found").into());
|
||||
return Err(
|
||||
serde_json::json!({ "code": "GROUP_NOT_FOUND" })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
self.save_groups_data(&groups_data)?;
|
||||
@@ -334,7 +350,7 @@ pub async fn create_profile_group(
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.create_group(&app_handle, name)
|
||||
.map_err(|e| format!("Failed to create group: {e}"))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -346,7 +362,7 @@ pub async fn update_profile_group(
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.update_group(&app_handle, group_id, name)
|
||||
.map_err(|e| format!("Failed to update group: {e}"))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -357,7 +373,7 @@ pub async fn delete_profile_group(
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.delete_group(&app_handle, group_id)
|
||||
.map_err(|e| format!("Failed to delete group: {e}"))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
+62
-6
@@ -43,6 +43,7 @@ pub mod proxy_runner;
|
||||
pub mod proxy_server;
|
||||
pub mod proxy_storage;
|
||||
mod settings_manager;
|
||||
pub mod socks5_local;
|
||||
pub mod sync;
|
||||
mod synchronizer;
|
||||
pub mod traffic_stats;
|
||||
@@ -150,6 +151,8 @@ use api_server::{get_api_server_status, start_api_server, stop_api_server};
|
||||
pub trait WindowExt {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_transparent_titlebar(&self, transparent: bool) -> Result<(), String>;
|
||||
#[cfg(target_os = "macos")]
|
||||
fn disable_native_fullscreen(&self) -> Result<(), String>;
|
||||
}
|
||||
|
||||
impl<R: Runtime> WindowExt for WebviewWindow<R> {
|
||||
@@ -164,7 +167,7 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
|
||||
|
||||
if transparent {
|
||||
// Hide the title text
|
||||
ns_window.setTitleVisibility(NSWindowTitleVisibility(2)); // NSWindowTitleHidden
|
||||
ns_window.setTitleVisibility(NSWindowTitleVisibility(1)); // NSWindowTitleHidden
|
||||
|
||||
// Make titlebar transparent
|
||||
ns_window.setTitlebarAppearsTransparent(true);
|
||||
@@ -189,6 +192,33 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn disable_native_fullscreen(&self) -> Result<(), String> {
|
||||
use objc2::rc::Retained;
|
||||
use objc2_app_kit::{NSWindow, NSWindowCollectionBehavior};
|
||||
|
||||
unsafe {
|
||||
let ns_window: Retained<NSWindow> =
|
||||
Retained::retain(self.ns_window().unwrap().cast()).unwrap();
|
||||
|
||||
// Make the green title-bar button (and titlebar double-click) "zoom"
|
||||
// the window to fill the screen as an ordinary window instead of
|
||||
// entering immersive native fullscreen that hides the menu bar and
|
||||
// moves to its own Space. Mirrors Electron's `fullscreenable: false`:
|
||||
// clear FullScreenPrimary and set FullScreenNone. AppKit then maps the
|
||||
// green button to the standard zoom, expanding to the visible screen
|
||||
// frame while keeping the window chrome and the current Space.
|
||||
const FULL_SCREEN_PRIMARY: usize = 1 << 7;
|
||||
const FULL_SCREEN_NONE: usize = 1 << 9;
|
||||
let current = ns_window.collectionBehavior();
|
||||
let updated =
|
||||
NSWindowCollectionBehavior((current.0 & !FULL_SCREEN_PRIMARY) | FULL_SCREEN_NONE);
|
||||
ns_window.setCollectionBehavior(updated);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Called internally for deep-link / startup URL handling — not invoked from the
|
||||
@@ -1272,13 +1302,18 @@ fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::E
|
||||
.item(&quit_item)
|
||||
.build()?;
|
||||
|
||||
// macOS uses a black template icon (the OS tints it for light/dark menu
|
||||
// bars). Windows and Linux use the full-color icon, because neither tints a
|
||||
// template — a black template would be invisible on dark Linux panels.
|
||||
// macOS uses the black icon as a template — the OS tints it for the light or
|
||||
// dark menu bar. Linux (and other non-Windows desktops) get a white-bodied
|
||||
// icon with a dark outline so it stays legible on both dark and light
|
||||
// panels: Tauri feeds the SNI/AppIndicator a fixed pixmap with no template
|
||||
// tinting, so the icon has to carry its own contrast (a solid black icon is
|
||||
// invisible on GNOME's dark top bar). Windows keeps its own solid icon.
|
||||
#[cfg(target_os = "macos")]
|
||||
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-44.png");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[cfg(target_os = "windows")]
|
||||
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-win-44.png");
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-linux-44.png");
|
||||
let tray_rgba = image::load_from_memory(tray_icon_bytes)?.into_rgba8();
|
||||
let (tray_w, tray_h) = tray_rgba.dimensions();
|
||||
let tray_image = tauri::image::Image::new_owned(tray_rgba.into_raw(), tray_w, tray_h);
|
||||
@@ -1388,6 +1423,21 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_macos_permissions::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
// Persist window size/position across restarts. VISIBLE is excluded
|
||||
// because the app hides to tray: restoring visibility would otherwise
|
||||
// relaunch with an invisible window after quitting from the tray while
|
||||
// hidden. FULLSCREEN is excluded because native fullscreen is disabled
|
||||
// (the green button zooms instead) — the maximized flag captures the
|
||||
// "filled screen" state, including green-button zoom on macOS.
|
||||
.plugin(
|
||||
tauri_plugin_window_state::Builder::default()
|
||||
.with_state_flags(
|
||||
tauri_plugin_window_state::StateFlags::all()
|
||||
& !tauri_plugin_window_state::StateFlags::VISIBLE
|
||||
& !tauri_plugin_window_state::StateFlags::FULLSCREEN,
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.setup(|app| {
|
||||
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
|
||||
ephemeral_dirs::recover_ephemeral_dirs();
|
||||
@@ -1403,7 +1453,8 @@ pub fn run() {
|
||||
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
||||
.title("Donut Browser")
|
||||
.inner_size(880.0, 500.0)
|
||||
.resizable(false)
|
||||
.min_inner_size(640.0, 400.0)
|
||||
.resizable(true)
|
||||
.fullscreen(false)
|
||||
.center()
|
||||
.focused(true)
|
||||
@@ -1447,6 +1498,11 @@ pub fn run() {
|
||||
if let Err(e) = window.set_transparent_titlebar(true) {
|
||||
log::warn!("Failed to set transparent titlebar: {e}");
|
||||
}
|
||||
// Green title-bar button maximizes (zoom) the window rather than
|
||||
// entering immersive native fullscreen.
|
||||
if let Err(e) = window.disable_native_fullscreen() {
|
||||
log::warn!("Failed to disable native fullscreen: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Set up deep link handler
|
||||
|
||||
+352
-32
@@ -152,11 +152,11 @@ impl McpServer {
|
||||
self.is_running.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
async fn require_paid_subscription(feature: &str) -> Result<(), McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
// Log the failed gate so customer logs explain why an MCP tool returned
|
||||
// an error. Include enough state (logged-in vs not, plan, status) for
|
||||
// support to diagnose without leaking secrets.
|
||||
/// Gate an MCP tool on a capability the caller already resolved (e.g.
|
||||
/// `CLOUD_AUTH.can_use_browser_automation().await`). Logs the rejected gate
|
||||
/// with enough state for support to diagnose, without leaking secrets.
|
||||
async fn require_capability(feature: &str, allowed: bool) -> Result<(), McpError> {
|
||||
if !allowed {
|
||||
let summary = match CLOUD_AUTH.get_user().await {
|
||||
Some(state) => format!(
|
||||
"logged_in=true plan={} status={} period={:?}",
|
||||
@@ -164,10 +164,10 @@ impl McpServer {
|
||||
),
|
||||
None => "logged_in=false".to_string(),
|
||||
};
|
||||
log::warn!("[mcp] Rejected '{feature}' — paid subscription gate failed ({summary})");
|
||||
log::warn!("[mcp] Rejected '{feature}' — plan does not include it ({summary})");
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: format!("{feature} requires an active paid subscription"),
|
||||
message: format!("{feature} requires a plan that includes this feature"),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
@@ -286,6 +286,9 @@ impl McpServer {
|
||||
.delete(Self::handle_mcp_delete),
|
||||
)
|
||||
.route("/health", get(Self::handle_health))
|
||||
// Inert chokepoint (innermost → runs after auth) for the future per-hour
|
||||
// automation request limit. See rate_limit_middleware.
|
||||
.layer(middleware::from_fn(Self::rate_limit_middleware))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
Self::auth_middleware,
|
||||
@@ -316,6 +319,17 @@ impl McpServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Chokepoint for the future per-hour automation request limit, mirroring the
|
||||
/// REST API's. The limit (`requests_per_hour`, default 100) is plumbed through
|
||||
/// entitlements; this is intentionally inert today — it resolves the limit but
|
||||
/// never blocks. To enforce, count authenticated tool calls per rolling hour
|
||||
/// and return StatusCode::TOO_MANY_REQUESTS once the limit (when > 0) is hit.
|
||||
async fn rate_limit_middleware(req: Request<Body>, next: Next) -> Result<Response, StatusCode> {
|
||||
let _requests_per_hour = CLOUD_AUTH.requests_per_hour().await;
|
||||
// TODO(rate-limit): enforce `_requests_per_hour` for MCP tool calls.
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
async fn auth_middleware(
|
||||
State(state): State<McpHttpState>,
|
||||
req: Request<Body>,
|
||||
@@ -339,8 +353,16 @@ impl McpServer {
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|h| h.strip_prefix("Bearer "));
|
||||
|
||||
let valid =
|
||||
path_token == Some(state.token.as_str()) || header_token == Some(state.token.as_str());
|
||||
// Constant-time comparison to avoid leaking the token prefix via timing.
|
||||
use subtle::ConstantTimeEq;
|
||||
let expected = state.token.as_bytes();
|
||||
let ct_eq = |t: Option<&str>| {
|
||||
t.is_some_and(|t| {
|
||||
let b = t.as_bytes();
|
||||
b.len() == expected.len() && b.ct_eq(expected).into()
|
||||
})
|
||||
};
|
||||
let valid = ct_eq(path_token) || ct_eq(header_token);
|
||||
|
||||
if !valid {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
@@ -508,7 +530,7 @@ impl McpServer {
|
||||
},
|
||||
McpTool {
|
||||
name: "run_profile".to_string(),
|
||||
description: "Launch a browser profile with an optional URL".to_string(),
|
||||
description: "Launch a browser profile with an optional URL. Requires an active Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -530,7 +552,7 @@ impl McpServer {
|
||||
},
|
||||
McpTool {
|
||||
name: "kill_profile".to_string(),
|
||||
description: "Stop a running browser profile".to_string(),
|
||||
description: "Stop a running browser profile. Requires an active Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -542,6 +564,44 @@ impl McpServer {
|
||||
"required": ["profile_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "batch_run_profiles".to_string(),
|
||||
description: "Launch multiple browser profiles at once with an optional URL. Requires an active Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "UUIDs of the profiles to launch"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Optional URL to open in every launched profile"
|
||||
},
|
||||
"headless": {
|
||||
"type": "boolean",
|
||||
"description": "Run the browsers in headless mode"
|
||||
}
|
||||
},
|
||||
"required": ["profile_ids"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "batch_stop_profiles".to_string(),
|
||||
description: "Stop multiple running browser profiles at once. Requires an active Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "UUIDs of the profiles to stop"
|
||||
}
|
||||
},
|
||||
"required": ["profile_ids"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "create_profile".to_string(),
|
||||
description: "Create a new browser profile".to_string(),
|
||||
@@ -1639,10 +1699,37 @@ impl McpServer {
|
||||
"list_profiles" => self.handle_list_profiles().await,
|
||||
"get_profile" => self.handle_get_profile(arguments).await,
|
||||
"run_profile" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_run_profile(arguments).await
|
||||
}
|
||||
"kill_profile" => self.handle_kill_profile(arguments).await,
|
||||
"kill_profile" => {
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_kill_profile(arguments).await
|
||||
}
|
||||
"batch_run_profiles" => {
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_batch_run_profiles(arguments).await
|
||||
}
|
||||
"batch_stop_profiles" => {
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_batch_stop_profiles(arguments).await
|
||||
}
|
||||
"create_profile" => self.handle_create_profile(arguments).await,
|
||||
"update_profile" => self.handle_update_profile(arguments).await,
|
||||
"delete_profile" => self.handle_delete_profile(arguments).await,
|
||||
@@ -1671,13 +1758,16 @@ impl McpServer {
|
||||
"connect_vpn" => self.handle_connect_vpn(arguments).await,
|
||||
"disconnect_vpn" => self.handle_disconnect_vpn(arguments).await,
|
||||
"get_vpn_status" => self.handle_get_vpn_status(arguments).await,
|
||||
// Fingerprint management — viewing and editing both require a paid plan.
|
||||
"get_profile_fingerprint" => {
|
||||
Self::require_paid_subscription("Fingerprint").await?;
|
||||
self.handle_get_profile_fingerprint(arguments).await
|
||||
}
|
||||
// Fingerprint management — viewing is free everywhere (matches the REST
|
||||
// API and the get_profile tool, which already expose the config); only
|
||||
// editing requires a paid plan.
|
||||
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(arguments).await,
|
||||
"update_profile_fingerprint" => {
|
||||
Self::require_paid_subscription("Fingerprint").await?;
|
||||
Self::require_capability(
|
||||
"Fingerprint editing",
|
||||
CLOUD_AUTH.can_use_cross_os_fingerprints().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_update_profile_fingerprint(arguments).await
|
||||
}
|
||||
"update_profile_proxy_bypass_rules" => {
|
||||
@@ -1706,7 +1796,11 @@ impl McpServer {
|
||||
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
|
||||
// Synchronizer tools
|
||||
"start_sync_session" => {
|
||||
Self::require_paid_subscription("Synchronizer").await?;
|
||||
Self::require_capability(
|
||||
"Synchronizer",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_start_sync_session(arguments).await
|
||||
}
|
||||
"stop_sync_session" => self.handle_stop_sync_session(arguments).await,
|
||||
@@ -1714,43 +1808,83 @@ impl McpServer {
|
||||
"remove_sync_follower" => self.handle_remove_sync_follower(arguments).await,
|
||||
// Browser interaction tools (require paid subscription)
|
||||
"navigate" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_navigate(arguments).await
|
||||
}
|
||||
"screenshot" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_screenshot(arguments).await
|
||||
}
|
||||
"evaluate_javascript" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_evaluate_javascript(arguments).await
|
||||
}
|
||||
"click_element" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_click_element(arguments).await
|
||||
}
|
||||
"type_text" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_type_text(arguments).await
|
||||
}
|
||||
"get_page_content" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_get_page_content(arguments).await
|
||||
}
|
||||
"get_page_info" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_get_page_info(arguments).await
|
||||
}
|
||||
"get_interactive_elements" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_get_interactive_elements(arguments).await
|
||||
}
|
||||
"click_by_index" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_click_by_index(arguments).await
|
||||
}
|
||||
"type_by_index" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
Self::require_capability(
|
||||
"Browser automation",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
self.handle_type_by_index(arguments).await
|
||||
}
|
||||
_ => Err(McpError {
|
||||
@@ -1829,6 +1963,13 @@ impl McpServer {
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
// Launching profiles programmatically requires the automation capability.
|
||||
Self::require_capability(
|
||||
"Launching a profile",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -1910,6 +2051,13 @@ impl McpServer {
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
// Stopping profiles programmatically requires the automation capability.
|
||||
Self::require_capability(
|
||||
"Killing a profile",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -1968,6 +2116,169 @@ impl McpServer {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_batch_run_profiles(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
Self::require_capability(
|
||||
"Batch launching profiles",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let profile_ids: Vec<String> = arguments
|
||||
.get("profile_ids")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing profile_ids array".to_string(),
|
||||
})?;
|
||||
|
||||
let url = arguments.get("url").and_then(|v| v.as_str());
|
||||
let headless = arguments
|
||||
.get("headless")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let profiles = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to list profiles: {e}"),
|
||||
})?;
|
||||
|
||||
// Clone the app handle and release the lock before the launch loop so we
|
||||
// never hold the inner mutex across the per-profile awaits.
|
||||
let app_handle = {
|
||||
let inner = self.inner.lock().await;
|
||||
inner
|
||||
.app_handle
|
||||
.as_ref()
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32000,
|
||||
message: "MCP server not properly initialized".to_string(),
|
||||
})?
|
||||
.clone()
|
||||
};
|
||||
|
||||
let mut launched = 0usize;
|
||||
let mut lines: Vec<String> = Vec::with_capacity(profile_ids.len());
|
||||
for profile_id in &profile_ids {
|
||||
let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
|
||||
lines.push(format!("{profile_id}: not found"));
|
||||
continue;
|
||||
};
|
||||
if profile.browser != "wayfern" && profile.browser != "camoufox" {
|
||||
lines.push(format!(
|
||||
"{profile_id}: unsupported browser (MCP supports Wayfern/Camoufox)"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = crate::team_lock::acquire_team_lock_if_needed(profile).await {
|
||||
lines.push(format!("{profile_id}: {e}"));
|
||||
continue;
|
||||
}
|
||||
match crate::browser_runner::launch_browser_profile_impl(
|
||||
app_handle.clone(),
|
||||
profile.clone(),
|
||||
url.map(|s| s.to_string()),
|
||||
None,
|
||||
headless,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
launched += 1;
|
||||
lines.push(format!("{}: launched", profile.name));
|
||||
}
|
||||
Err(e) => lines.push(format!("{}: launch failed: {e}", profile.name)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Launched {}/{} profile(s):\n{}", launched, profile_ids.len(), lines.join("\n"))
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_batch_stop_profiles(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
Self::require_capability(
|
||||
"Batch stopping profiles",
|
||||
CLOUD_AUTH.can_use_browser_automation().await,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let profile_ids: Vec<String> = arguments
|
||||
.get("profile_ids")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing profile_ids array".to_string(),
|
||||
})?;
|
||||
|
||||
let profiles = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to list profiles: {e}"),
|
||||
})?;
|
||||
|
||||
let app_handle = {
|
||||
let inner = self.inner.lock().await;
|
||||
inner
|
||||
.app_handle
|
||||
.as_ref()
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32000,
|
||||
message: "MCP server not properly initialized".to_string(),
|
||||
})?
|
||||
.clone()
|
||||
};
|
||||
|
||||
let mut stopped = 0usize;
|
||||
let mut lines: Vec<String> = Vec::with_capacity(profile_ids.len());
|
||||
for profile_id in &profile_ids {
|
||||
let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
|
||||
lines.push(format!("{profile_id}: not found"));
|
||||
continue;
|
||||
};
|
||||
match crate::browser_runner::BrowserRunner::instance()
|
||||
.kill_browser_process(app_handle.clone(), profile)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
crate::team_lock::release_team_lock_if_needed(profile).await;
|
||||
stopped += 1;
|
||||
lines.push(format!("{}: stopped", profile.name));
|
||||
}
|
||||
Err(e) => lines.push(format!("{}: stop failed: {e}", profile.name)),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Stopped {}/{} profile(s):\n{}", stopped, profile_ids.len(), lines.join("\n"))
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_create_profile(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
@@ -2586,6 +2897,15 @@ impl McpServer {
|
||||
message: "Missing proxy_type".to_string(),
|
||||
})?;
|
||||
|
||||
// The tool schema declares an enum, but JSON-Schema enums are advisory only;
|
||||
// enforce it here so a bad value can't produce a non-functional proxy.
|
||||
if !matches!(proxy_type, "http" | "https" | "socks4" | "socks5") {
|
||||
return Err(McpError {
|
||||
code: -32602,
|
||||
message: "proxy_type must be one of: http, https, socks4, socks5".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let host = arguments
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -3237,10 +3557,10 @@ impl McpServer {
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
if !CLOUD_AUTH.can_use_cross_os_fingerprints().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Fingerprint editing requires an active Pro subscription".to_string(),
|
||||
message: "Fingerprint editing requires a plan that includes it".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,42 @@ use crate::profile::BrowserProfile;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// True if a process command line refers to `profile_path` as a real browser
|
||||
/// profile/data-dir argument, NOT merely a substring. A bare `contains` match
|
||||
/// force-killed unrelated processes that happened to mention the path (editors,
|
||||
/// `tail`, a terminal that `cd`'d there, or another profile whose path has this
|
||||
/// one as a prefix). Mirrors the precise matching in browser_runner/wayfern_manager.
|
||||
///
|
||||
/// Only the macOS and Linux process-kill paths use this; Windows has no
|
||||
/// `find_processes_by_profile_path`, so gate it to avoid a dead-code error there.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn cmd_matches_profile_path(cmd: &[std::ffi::OsString], profile_path: &str) -> bool {
|
||||
let args: Vec<&str> = cmd.iter().filter_map(|a| a.to_str()).collect();
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
// Exact argument equality (Firefox/Camoufox: `-profile <path>`; some launchers
|
||||
// pass the path as its own arg).
|
||||
if *arg == profile_path {
|
||||
return true;
|
||||
}
|
||||
// `--user-data-dir=<path>` (Chromium/Wayfern) or `-profile=<path>`.
|
||||
if let Some(val) = arg
|
||||
.strip_prefix("--user-data-dir=")
|
||||
.or_else(|| arg.strip_prefix("-profile="))
|
||||
{
|
||||
if val == profile_path {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Flag followed by the path as the next argument.
|
||||
if (*arg == "-profile" || *arg == "--user-data-dir")
|
||||
&& args.get(i + 1).is_some_and(|next| *next == profile_path)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// Platform-specific modules
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(dead_code)]
|
||||
@@ -215,16 +251,7 @@ pub mod macos {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if any command line argument contains the profile path
|
||||
let has_profile = cmd.iter().any(|arg| {
|
||||
if let Some(arg_str) = arg.to_str() {
|
||||
arg_str.contains(profile_path)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if has_profile {
|
||||
if cmd_matches_profile_path(cmd, profile_path) {
|
||||
pids.push(pid.as_u32());
|
||||
}
|
||||
}
|
||||
@@ -607,19 +634,25 @@ pub mod linux {
|
||||
}
|
||||
}
|
||||
|
||||
// Additional Linux-specific environment variables for better compatibility
|
||||
cmd.env(
|
||||
"DISPLAY",
|
||||
std::env::var("DISPLAY").unwrap_or(":0".to_string()),
|
||||
);
|
||||
// Propagate DISPLAY only when this session actually has an X11 display.
|
||||
// Forcing DISPLAY=:0 breaks Wayland-only sessions (there is no X server on
|
||||
// :0, so any X11 client launched with it set will fail to connect). When
|
||||
// DISPLAY is set the child already inherits it from our environment, so
|
||||
// setting it explicitly here is purely defensive; when it's unset we leave
|
||||
// it unset and let the browser use Wayland.
|
||||
if let Ok(display) = std::env::var("DISPLAY") {
|
||||
cmd.env("DISPLAY", display);
|
||||
}
|
||||
|
||||
// Set MOZ_ENABLE_WAYLAND for better Wayland support
|
||||
if std::env::var("WAYLAND_DISPLAY").is_ok() {
|
||||
cmd.env("MOZ_ENABLE_WAYLAND", "1");
|
||||
}
|
||||
|
||||
// Disable GPU acceleration if running in headless environments
|
||||
if std::env::var("DISPLAY").is_err() || std::env::var("WAYLAND_DISPLAY").is_err() {
|
||||
// Warn only when running truly headless — i.e. NEITHER X11 nor Wayland is
|
||||
// available. Using OR here would fire on every normal Wayland-only session
|
||||
// (DISPLAY unset) or X11-only session (WAYLAND_DISPLAY unset).
|
||||
if std::env::var("DISPLAY").is_err() && std::env::var("WAYLAND_DISPLAY").is_err() {
|
||||
log::info!("No display detected, browser may fail to start");
|
||||
}
|
||||
|
||||
@@ -832,15 +865,7 @@ pub mod linux {
|
||||
continue;
|
||||
}
|
||||
|
||||
let has_profile = cmd.iter().any(|arg| {
|
||||
if let Some(arg_str) = arg.to_str() {
|
||||
arg_str.contains(profile_path)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if has_profile {
|
||||
if cmd_matches_profile_path(cmd, profile_path) {
|
||||
pids.push(pid.as_u32());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,6 +326,19 @@ impl ProfileManager {
|
||||
log::info!("Using provided fingerprint for Wayfern profile: {name}");
|
||||
}
|
||||
|
||||
// Record which proxy/geoip the fingerprint's location data was computed
|
||||
// for. On launch this is compared against the profile's current routing
|
||||
// so a proxy that was changed after creation triggers a location refresh
|
||||
// instead of showing a stale timezone.
|
||||
config.geo_proxy_signature = Some(crate::wayfern_manager::WayfernManager::geo_signature(
|
||||
proxy_id
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id))
|
||||
.as_ref(),
|
||||
None,
|
||||
config.geoip.as_ref(),
|
||||
));
|
||||
|
||||
// Clear the proxy from config after fingerprint generation
|
||||
config.proxy = None;
|
||||
|
||||
@@ -1035,7 +1048,7 @@ impl ProfileManager {
|
||||
fs::create_dir_all(&dest_dir)?;
|
||||
}
|
||||
|
||||
let new_profile = BrowserProfile {
|
||||
let mut new_profile = BrowserProfile {
|
||||
id: new_id,
|
||||
name: clone_name,
|
||||
browser: source.browser,
|
||||
@@ -1071,6 +1084,21 @@ impl ProfileManager {
|
||||
updated_at: Some(crate::proxy_manager::now_secs()),
|
||||
};
|
||||
|
||||
// Donut: a clone must NOT be linkable to its source. The source
|
||||
// wayfern_config embeds the persisted fingerprint JSON (including the
|
||||
// canvas_noise_seed), so copying it verbatim makes the clone emit
|
||||
// BYTE-IDENTICAL canvas/WebGL/audio readback hashes and identical device
|
||||
// signals as the source — trivially linkable if both run concurrently. Clear
|
||||
// the fingerprint so the launch path mints a fresh one (a new
|
||||
// canvas_noise_seed via RandBytes + an independent device fingerprint),
|
||||
// exactly as create_profile does when fingerprint.is_none(). NOTE: the
|
||||
// user-data-dir copy above still duplicates cookies/localStorage/TLS state —
|
||||
// a separate storage-linkage vector the user must clear if they want full
|
||||
// isolation between a clone and its source.
|
||||
if let Some(cfg) = new_profile.wayfern_config.as_mut() {
|
||||
cfg.fingerprint = None;
|
||||
}
|
||||
|
||||
self.save_profile(&new_profile)?;
|
||||
|
||||
if let Err(e) = events::emit_empty("profiles-changed") {
|
||||
@@ -2501,7 +2529,7 @@ pub async fn update_camoufox_config(
|
||||
) -> Result<(), String> {
|
||||
if config.fingerprint.is_some()
|
||||
&& !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.can_use_cross_os_fingerprints()
|
||||
.await
|
||||
{
|
||||
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
|
||||
@@ -2529,7 +2557,7 @@ pub async fn update_wayfern_config(
|
||||
) -> Result<(), String> {
|
||||
if config.fingerprint.is_some()
|
||||
&& !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.can_use_cross_os_fingerprints()
|
||||
.await
|
||||
{
|
||||
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
|
||||
|
||||
@@ -2,7 +2,7 @@ use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fs::{self, create_dir_all};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::camoufox_manager::CamoufoxConfig;
|
||||
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
||||
@@ -21,11 +21,11 @@ pub struct DetectedProfile {
|
||||
}
|
||||
|
||||
fn map_browser_type(browser: &str) -> &str {
|
||||
// Firefox-based sources map to the now-deprecated Camoufox. They are no longer
|
||||
// detected for import; the mapping is kept only so the import command can
|
||||
// recognize and REJECT them. Everything else maps to Wayfern.
|
||||
match browser {
|
||||
"firefox" | "firefox-developer" | "zen" => "camoufox",
|
||||
"chromium" | "brave" => "wayfern",
|
||||
"camoufox" => "camoufox",
|
||||
"wayfern" => "wayfern",
|
||||
"firefox" | "firefox-developer" | "zen" | "camoufox" => "camoufox",
|
||||
_ => "wayfern",
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ pub struct ProfileImporter {
|
||||
base_dirs: BaseDirs,
|
||||
downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
|
||||
profile_manager: &'static ProfileManager,
|
||||
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
|
||||
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
|
||||
}
|
||||
|
||||
@@ -44,7 +43,6 @@ impl ProfileImporter {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
|
||||
profile_manager: ProfileManager::instance(),
|
||||
camoufox_manager: crate::camoufox_manager::CamoufoxManager::instance(),
|
||||
wayfern_manager: crate::wayfern_manager::WayfernManager::instance(),
|
||||
}
|
||||
}
|
||||
@@ -58,12 +56,12 @@ impl ProfileImporter {
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut detected_profiles = Vec::new();
|
||||
|
||||
detected_profiles.extend(self.detect_firefox_profiles()?);
|
||||
// Firefox-based browsers (Firefox, Firefox Developer, Zen) map to Camoufox,
|
||||
// which is deprecated — they can no longer be imported. Only Chromium-based
|
||||
// sources (mapping to Wayfern) are detected.
|
||||
detected_profiles.extend(self.detect_chrome_profiles()?);
|
||||
detected_profiles.extend(self.detect_brave_profiles()?);
|
||||
detected_profiles.extend(self.detect_firefox_developer_profiles()?);
|
||||
detected_profiles.extend(self.detect_chromium_profiles()?);
|
||||
detected_profiles.extend(self.detect_zen_browser_profiles()?);
|
||||
|
||||
let mut seen_paths = HashSet::new();
|
||||
let unique_profiles: Vec<DetectedProfile> = detected_profiles
|
||||
@@ -74,80 +72,6 @@ impl ProfileImporter {
|
||||
Ok(unique_profiles)
|
||||
}
|
||||
|
||||
fn detect_firefox_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let firefox_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/Firefox/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
let firefox_dir = app_data.join("Mozilla/Firefox/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
|
||||
|
||||
let local_app_data = self.base_dirs.data_local_dir();
|
||||
let firefox_local_dir = local_app_data.join("Mozilla/Firefox/Profiles");
|
||||
if firefox_local_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_local_dir, "firefox")?);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let firefox_dir = self.base_dirs.home_dir().join(".mozilla/firefox");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn detect_firefox_developer_profiles(
|
||||
&self,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let firefox_dev_alt_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/Firefox Developer Edition/Profiles");
|
||||
|
||||
if firefox_dev_alt_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_alt_dir, "firefox-developer")?);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
let firefox_dev_dir = app_data.join("Mozilla/Firefox Developer Edition/Profiles");
|
||||
if firefox_dev_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let firefox_dev_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join(".mozilla/firefox-dev-edition");
|
||||
if firefox_dev_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn detect_chrome_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
@@ -235,191 +159,6 @@ impl ProfileImporter {
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn detect_zen_browser_profiles(
|
||||
&self,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let zen_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/Zen/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
let zen_dir = app_data.join("Zen/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let zen_dir = self.base_dirs.home_dir().join(".zen");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn scan_firefox_profiles_dir(
|
||||
&self,
|
||||
profiles_dir: &Path,
|
||||
browser_type: &str,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
if !profiles_dir.exists() {
|
||||
return Ok(profiles);
|
||||
}
|
||||
|
||||
let profiles_ini = profiles_dir
|
||||
.parent()
|
||||
.unwrap_or(profiles_dir)
|
||||
.join("profiles.ini");
|
||||
if profiles_ini.exists() {
|
||||
if let Ok(content) = fs::read_to_string(&profiles_ini) {
|
||||
profiles.extend(self.parse_firefox_profiles_ini(&content, profiles_dir, browser_type)?);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(entries) = fs::read_dir(profiles_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let prefs_file = path.join("prefs.js");
|
||||
if prefs_file.exists() {
|
||||
let profile_name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("Unknown Profile");
|
||||
|
||||
let already_added = profiles.iter().any(|p| p.path == path.to_string_lossy());
|
||||
if !already_added {
|
||||
profiles.push(DetectedProfile {
|
||||
browser: browser_type.to_string(),
|
||||
mapped_browser: map_browser_type(browser_type).to_string(),
|
||||
name: format!(
|
||||
"{} Profile - {}",
|
||||
self.get_browser_display_name(browser_type),
|
||||
profile_name
|
||||
),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
description: format!("Profile folder: {profile_name}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn parse_firefox_profiles_ini(
|
||||
&self,
|
||||
content: &str,
|
||||
profiles_dir: &Path,
|
||||
browser_type: &str,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
let mut current_section = String::new();
|
||||
let mut profile_name = String::new();
|
||||
let mut profile_path = String::new();
|
||||
let mut is_relative = true;
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
if line.starts_with('[') && line.ends_with(']') {
|
||||
if !current_section.is_empty()
|
||||
&& current_section.starts_with("Profile")
|
||||
&& !profile_path.is_empty()
|
||||
{
|
||||
let full_path = if is_relative {
|
||||
profiles_dir.join(&profile_path)
|
||||
} else {
|
||||
PathBuf::from(&profile_path)
|
||||
};
|
||||
|
||||
if full_path.exists() {
|
||||
let display_name = if profile_name.is_empty() {
|
||||
format!("{} Profile", self.get_browser_display_name(browser_type))
|
||||
} else {
|
||||
format!(
|
||||
"{} - {}",
|
||||
self.get_browser_display_name(browser_type),
|
||||
profile_name
|
||||
)
|
||||
};
|
||||
|
||||
profiles.push(DetectedProfile {
|
||||
browser: browser_type.to_string(),
|
||||
mapped_browser: map_browser_type(browser_type).to_string(),
|
||||
name: display_name,
|
||||
path: full_path.to_string_lossy().to_string(),
|
||||
description: format!("Profile: {profile_name}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
current_section = line[1..line.len() - 1].to_string();
|
||||
profile_name.clear();
|
||||
profile_path.clear();
|
||||
is_relative = true;
|
||||
} else if line.contains('=') {
|
||||
let parts: Vec<&str> = line.splitn(2, '=').collect();
|
||||
if parts.len() == 2 {
|
||||
let key = parts[0].trim();
|
||||
let value = parts[1].trim();
|
||||
|
||||
match key {
|
||||
"Name" => profile_name = value.to_string(),
|
||||
"Path" => profile_path = value.to_string(),
|
||||
"IsRelative" => is_relative = value == "1",
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !current_section.is_empty()
|
||||
&& current_section.starts_with("Profile")
|
||||
&& !profile_path.is_empty()
|
||||
{
|
||||
let full_path = if is_relative {
|
||||
profiles_dir.join(&profile_path)
|
||||
} else {
|
||||
PathBuf::from(&profile_path)
|
||||
};
|
||||
|
||||
if full_path.exists() {
|
||||
let display_name = if profile_name.is_empty() {
|
||||
format!("{} Profile", self.get_browser_display_name(browser_type))
|
||||
} else {
|
||||
format!(
|
||||
"{} - {}",
|
||||
self.get_browser_display_name(browser_type),
|
||||
profile_name
|
||||
)
|
||||
};
|
||||
|
||||
profiles.push(DetectedProfile {
|
||||
browser: browser_type.to_string(),
|
||||
mapped_browser: map_browser_type(browser_type).to_string(),
|
||||
name: display_name,
|
||||
path: full_path.to_string_lossy().to_string(),
|
||||
description: format!("Profile: {profile_name}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
fn scan_chrome_profiles_dir(
|
||||
&self,
|
||||
browser_dir: &Path,
|
||||
@@ -493,7 +232,7 @@ impl ProfileImporter {
|
||||
browser_type: &str,
|
||||
new_profile_name: &str,
|
||||
proxy_id: Option<String>,
|
||||
camoufox_config: Option<CamoufoxConfig>,
|
||||
_camoufox_config: Option<CamoufoxConfig>,
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let source_path = Path::new(source_path);
|
||||
@@ -529,88 +268,9 @@ impl ProfileImporter {
|
||||
|
||||
let version = self.get_default_version_for_browser(mapped)?;
|
||||
|
||||
let final_camoufox_config = if mapped == "camoufox" {
|
||||
let mut config = camoufox_config.unwrap_or_default();
|
||||
|
||||
if let Some(ref proxy_id_val) = proxy_id {
|
||||
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_val) {
|
||||
let proxy_url = if let (Some(username), Some(password)) =
|
||||
(&proxy_settings.username, &proxy_settings.password)
|
||||
{
|
||||
format!(
|
||||
"{}://{}:{}@{}:{}",
|
||||
proxy_settings.proxy_type.to_lowercase(),
|
||||
username,
|
||||
password,
|
||||
proxy_settings.host,
|
||||
proxy_settings.port
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}://{}:{}",
|
||||
proxy_settings.proxy_type.to_lowercase(),
|
||||
proxy_settings.host,
|
||||
proxy_settings.port
|
||||
)
|
||||
};
|
||||
config.proxy = Some(proxy_url);
|
||||
}
|
||||
}
|
||||
|
||||
if config.fingerprint.is_none() {
|
||||
let temp_profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: new_profile_name.to_string(),
|
||||
browser: mapped.to_string(),
|
||||
version: version.clone(),
|
||||
proxy_id: proxy_id.clone(),
|
||||
vpn_id: None,
|
||||
launch_hook: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
wayfern_config: None,
|
||||
group_id: None,
|
||||
tags: Vec::new(),
|
||||
note: None,
|
||||
sync_mode: SyncMode::Disabled,
|
||||
encryption_salt: None,
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral: false,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
match self
|
||||
.camoufox_manager
|
||||
.generate_fingerprint_config(app_handle, &temp_profile, &config)
|
||||
.await
|
||||
{
|
||||
Ok(fp) => config.fingerprint = Some(fp),
|
||||
Err(e) => {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to generate fingerprint for imported profile '{new_profile_name}': {e}"
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config.proxy = None;
|
||||
Some(config)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Camoufox import is removed; only Wayfern profiles are imported now, so the
|
||||
// imported profile never carries a Camoufox config.
|
||||
let final_camoufox_config: Option<CamoufoxConfig> = None;
|
||||
|
||||
let final_wayfern_config = if mapped == "wayfern" {
|
||||
let mut config = wayfern_config.unwrap_or_default();
|
||||
@@ -806,6 +466,12 @@ pub async fn import_browser_profile(
|
||||
camoufox_config: Option<CamoufoxConfig>,
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
) -> Result<(), String> {
|
||||
// Camoufox is deprecated — Firefox-based profiles (which map to Camoufox) can
|
||||
// no longer be imported. Reject them before doing any work.
|
||||
if map_browser_type(&browser_type) == "camoufox" {
|
||||
return Err(serde_json::json!({ "code": "CAMOUFOX_IMPORT_DEPRECATED" }).to_string());
|
||||
}
|
||||
|
||||
let fingerprint_os = camoufox_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.os.as_deref())
|
||||
@@ -897,24 +563,6 @@ mod tests {
|
||||
let _profiles = result.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_firefox_profiles_dir_nonexistent() {
|
||||
let (importer, temp_dir) = create_test_profile_importer();
|
||||
|
||||
let nonexistent_dir = temp_dir.path().join("nonexistent");
|
||||
let result = importer.scan_firefox_profiles_dir(&nonexistent_dir, "firefox");
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Should handle nonexistent directory gracefully"
|
||||
);
|
||||
let profiles = result.unwrap();
|
||||
assert!(
|
||||
profiles.is_empty(),
|
||||
"Should return empty vector for nonexistent directory"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_chrome_profiles_dir_nonexistent() {
|
||||
let (importer, temp_dir) = create_test_profile_importer();
|
||||
@@ -933,51 +581,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_firefox_profiles_ini_empty() {
|
||||
let (importer, _temp_dir) = create_test_profile_importer();
|
||||
|
||||
let empty_content = "";
|
||||
let profiles_dir = Path::new("/tmp");
|
||||
let result = importer.parse_firefox_profiles_ini(empty_content, profiles_dir, "firefox");
|
||||
|
||||
assert!(result.is_ok(), "Should handle empty profiles.ini");
|
||||
let profiles = result.unwrap();
|
||||
assert!(
|
||||
profiles.is_empty(),
|
||||
"Should return empty vector for empty content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_firefox_profiles_ini_valid() {
|
||||
let (importer, temp_dir) = create_test_profile_importer();
|
||||
|
||||
let profiles_dir = temp_dir.path().join("profiles");
|
||||
let profile_dir = profiles_dir.join("test.profile");
|
||||
fs::create_dir_all(&profile_dir).expect("Should create profile directory");
|
||||
|
||||
let prefs_file = profile_dir.join("prefs.js");
|
||||
fs::write(&prefs_file, "// Firefox preferences").expect("Should create prefs.js");
|
||||
|
||||
let profiles_ini_content = r#"
|
||||
[Profile0]
|
||||
Name=Test Profile
|
||||
IsRelative=1
|
||||
Path=test.profile
|
||||
"#;
|
||||
|
||||
let result =
|
||||
importer.parse_firefox_profiles_ini(profiles_ini_content, &profiles_dir, "firefox");
|
||||
|
||||
assert!(result.is_ok(), "Should parse valid profiles.ini");
|
||||
let profiles = result.unwrap();
|
||||
assert_eq!(profiles.len(), 1, "Should find one profile");
|
||||
assert_eq!(profiles[0].name, "Firefox - Test Profile");
|
||||
assert_eq!(profiles[0].browser, "firefox");
|
||||
assert_eq!(profiles[0].mapped_browser, "camoufox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copy_directory_recursive() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
|
||||
@@ -774,6 +774,17 @@ impl ProxyManager {
|
||||
list
|
||||
}
|
||||
|
||||
/// Insert/replace a stored proxy in the in-memory map. Used by sync's
|
||||
/// download_proxy after it writes the file to disk, mirroring how
|
||||
/// download_group/download_vpn/download_extension keep their managers'
|
||||
/// in-memory state in sync. Without this, get_stored_proxies (which reads
|
||||
/// only the map) never sees a downloaded proxy until restart, so sync keeps
|
||||
/// re-downloading it indefinitely.
|
||||
pub fn upsert_stored_proxy(&self, proxy: StoredProxy) {
|
||||
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
stored_proxies.insert(proxy.id.clone(), proxy);
|
||||
}
|
||||
|
||||
// Get a stored proxy by ID
|
||||
|
||||
// Update a stored proxy
|
||||
@@ -1467,6 +1478,7 @@ impl ProxyManager {
|
||||
|
||||
// Start a proxy for given proxy settings and associate it with a browser process ID
|
||||
// If proxy_settings is None, starts a direct proxy for traffic monitoring
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn start_proxy(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -1475,6 +1487,10 @@ impl ProxyManager {
|
||||
profile_id: Option<&str>,
|
||||
bypass_rules: Vec<String>,
|
||||
blocklist_file: Option<String>,
|
||||
// Protocol the local worker serves the browser: "http" (Camoufox) or
|
||||
// "socks5" (Wayfern). Reflected in the returned ProxySettings.proxy_type
|
||||
// so the caller formats the right local proxy URL scheme.
|
||||
local_protocol: &str,
|
||||
) -> Result<ProxySettings, String> {
|
||||
if let Some(name) = profile_id {
|
||||
// Check if we have an active proxy recorded for this profile
|
||||
@@ -1508,7 +1524,7 @@ impl ProxyManager {
|
||||
if proxies.contains_key(&browser_pid) {
|
||||
// Already mapped, reuse it
|
||||
return Ok(ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
proxy_type: local_protocol.to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: existing.local_port,
|
||||
username: None,
|
||||
@@ -1548,7 +1564,7 @@ impl ProxyManager {
|
||||
if profile_id_matches {
|
||||
// Reuse existing local proxy (settings and profile_id match)
|
||||
return Ok(ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
proxy_type: local_protocol.to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: existing.local_port,
|
||||
username: None,
|
||||
@@ -1607,6 +1623,9 @@ impl ProxyManager {
|
||||
proxy_cmd = proxy_cmd.arg("--blocklist-file").arg(path);
|
||||
}
|
||||
|
||||
// Tell the worker which protocol to serve the browser (http or socks5)
|
||||
proxy_cmd = proxy_cmd.arg("--local-protocol").arg(local_protocol);
|
||||
|
||||
// Execute the command and wait for it to complete
|
||||
// The donut-proxy binary should start the worker and then exit
|
||||
let output = proxy_cmd
|
||||
@@ -1698,7 +1717,7 @@ impl ProxyManager {
|
||||
|
||||
// Return proxy settings for the browser
|
||||
Ok(ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
proxy_type: local_protocol.to_string(),
|
||||
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
|
||||
port: proxy_info.local_port,
|
||||
username: None,
|
||||
@@ -1730,12 +1749,18 @@ impl ProxyManager {
|
||||
.arg("--id")
|
||||
.arg(&proxy_id);
|
||||
|
||||
let output = proxy_cmd.output().await.unwrap();
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::warn!("Proxy stop error: {stderr}");
|
||||
// We still return Ok since we've already removed the proxy from our tracking
|
||||
// A failed spawn (sidecar missing, permission denied, fd exhaustion) must
|
||||
// not panic the cleanup task — the proxy is already removed from tracking,
|
||||
// so degrade gracefully like the non-success branch below.
|
||||
match proxy_cmd.output().await {
|
||||
Ok(output) if !output.status.success() => {
|
||||
log::warn!(
|
||||
"Proxy stop error: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("Failed to run donut-proxy stop: {e}"),
|
||||
}
|
||||
|
||||
// Clear profile-to-proxy mapping if it references this proxy
|
||||
@@ -1795,11 +1820,16 @@ impl ProxyManager {
|
||||
.arg("--id")
|
||||
.arg(&proxy_id);
|
||||
|
||||
let output = proxy_cmd.output().await.unwrap();
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::warn!("Proxy stop error: {stderr}");
|
||||
// Don't panic if the sidecar can't be spawned — still clear the mapping.
|
||||
match proxy_cmd.output().await {
|
||||
Ok(output) if !output.status.success() => {
|
||||
log::warn!(
|
||||
"Proxy stop error: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("Failed to run donut-proxy stop: {e}"),
|
||||
}
|
||||
|
||||
// Clear profile-to-proxy mapping
|
||||
@@ -1830,6 +1860,38 @@ impl ProxyManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the real browser PID onto the worker's on-disk config so the
|
||||
/// detached worker can self-terminate when that browser dies, independent of
|
||||
/// the GUI being alive. Resolved via the profile→proxy_id map rather than the
|
||||
/// PID-keyed `active_proxies` map: the latter uses a placeholder key 0 during
|
||||
/// launch that collides across concurrent launches, which could tag a live
|
||||
/// worker with the wrong (dead) PID and make it self-exit. Safe on the reuse
|
||||
/// path — it simply rewrites `browser_pid` to the new live PID. A `browser_pid`
|
||||
/// of 0 (launch failed to report a PID) is ignored so the worker never
|
||||
/// self-exits against a bogus PID.
|
||||
pub fn set_browser_pid_for_profile(&self, profile_id: &str, browser_pid: u32) {
|
||||
if browser_pid == 0 {
|
||||
return;
|
||||
}
|
||||
let proxy_id = {
|
||||
let map = self.profile_active_proxy_ids.lock().unwrap();
|
||||
match map.get(profile_id) {
|
||||
Some(id) => id.clone(),
|
||||
None => return, // No local worker for this profile — nothing to tag.
|
||||
}
|
||||
};
|
||||
if let Some(mut cfg) = crate::proxy_storage::get_proxy_config(&proxy_id) {
|
||||
cfg.browser_pid = Some(browser_pid);
|
||||
if crate::proxy_storage::update_proxy_config(&cfg) {
|
||||
log::info!(
|
||||
"Recorded browser PID {browser_pid} on proxy config {proxy_id} for self-reaping"
|
||||
);
|
||||
} else {
|
||||
log::warn!("Failed to persist browser_pid {browser_pid} to proxy config {proxy_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up proxies for dead browser processes
|
||||
// Only clean up orphaned config files where the proxy process itself is dead
|
||||
pub async fn cleanup_dead_proxies(
|
||||
@@ -2863,6 +2925,8 @@ mod tests {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
};
|
||||
let dead_config = ProxyConfig {
|
||||
id: dead_id.clone(),
|
||||
@@ -2874,6 +2938,8 @@ mod tests {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
};
|
||||
|
||||
save_proxy_config(&live_config).unwrap();
|
||||
@@ -2913,6 +2979,8 @@ mod tests {
|
||||
profile_id: Some("prof_abc".to_string()),
|
||||
bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()],
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
};
|
||||
|
||||
// Save
|
||||
@@ -3231,6 +3299,8 @@ mod tests {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
};
|
||||
save_proxy_config(&config).unwrap();
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ pub async fn start_proxy_process(
|
||||
upstream_url: Option<String>,
|
||||
port: Option<u16>,
|
||||
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
||||
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None).await
|
||||
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None, None).await
|
||||
}
|
||||
|
||||
pub async fn start_proxy_process_with_profile(
|
||||
@@ -169,6 +169,7 @@ pub async fn start_proxy_process_with_profile(
|
||||
profile_id: Option<String>,
|
||||
bypass_rules: Vec<String>,
|
||||
blocklist_file: Option<String>,
|
||||
local_protocol: Option<String>,
|
||||
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
||||
let id = generate_proxy_id();
|
||||
let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string());
|
||||
@@ -183,7 +184,8 @@ pub async fn start_proxy_process_with_profile(
|
||||
let config = ProxyConfig::new(id.clone(), upstream, Some(local_port))
|
||||
.with_profile_id(profile_id.clone())
|
||||
.with_bypass_rules(bypass_rules)
|
||||
.with_blocklist_file(blocklist_file);
|
||||
.with_blocklist_file(blocklist_file)
|
||||
.with_local_protocol(local_protocol);
|
||||
save_proxy_config(&config)?;
|
||||
|
||||
// Log profile_id for debugging
|
||||
|
||||
+536
-116
@@ -7,13 +7,13 @@ use hyper::service::service_fn;
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use regex_lite::Regex;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::convert::Infallible;
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
|
||||
use tokio::net::TcpStream;
|
||||
@@ -21,9 +21,9 @@ use tokio::net::TcpStream;
|
||||
/// Combined read+write trait for tunnel target streams, allowing
|
||||
/// `handle_connect_from_buffer` to handle plain TCP, SOCKS, and
|
||||
/// Shadowsocks through the same bidirectional-copy path.
|
||||
trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
pub(crate) trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsyncStream for T {}
|
||||
type BoxedAsyncStream = Box<dyn AsyncStream>;
|
||||
pub(crate) type BoxedAsyncStream = Box<dyn AsyncStream>;
|
||||
use url::Url;
|
||||
|
||||
enum CompiledRule {
|
||||
@@ -326,19 +326,15 @@ async fn handle_connect(
|
||||
let port = upstream.port().unwrap_or(1080);
|
||||
let socks_addr = format!("{}:{}", host, port);
|
||||
|
||||
let username = upstream.username();
|
||||
let password = upstream.password().unwrap_or("");
|
||||
let (username, password) = upstream_userpass(&upstream);
|
||||
let auth = (!username.is_empty()).then_some((username.as_str(), password.as_str()));
|
||||
|
||||
match connect_via_socks(
|
||||
&socks_addr,
|
||||
target_host,
|
||||
target_port,
|
||||
scheme == "socks5",
|
||||
if !username.is_empty() {
|
||||
Some((username, password))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
auth,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -378,7 +374,12 @@ async fn connect_via_http_proxy(
|
||||
) -> Result<TcpStream, Box<dyn std::error::Error>> {
|
||||
let proxy_host = upstream.host_str().unwrap_or("127.0.0.1");
|
||||
let proxy_port = upstream.port().unwrap_or(8080);
|
||||
let mut stream = TcpStream::connect((proxy_host, proxy_port)).await?;
|
||||
let mut stream = tokio::time::timeout(
|
||||
UPSTREAM_DIAL_TIMEOUT,
|
||||
TcpStream::connect((proxy_host, proxy_port)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| format!("upstream proxy connect to {proxy_host}:{proxy_port} timed out"))??;
|
||||
|
||||
// Add proxy authentication if provided
|
||||
let mut connect_req = format!(
|
||||
@@ -386,10 +387,9 @@ async fn connect_via_http_proxy(
|
||||
target_host, target_port, target_host, target_port
|
||||
);
|
||||
|
||||
if !upstream.username().is_empty() {
|
||||
let (username, password) = upstream_userpass(upstream);
|
||||
if !username.is_empty() {
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
let username = upstream.username();
|
||||
let password = upstream.password().unwrap_or("");
|
||||
let auth = general_purpose::STANDARD.encode(format!("{}:{}", username, password));
|
||||
connect_req.push_str(&format!("Proxy-Authorization: Basic {}\r\n", auth));
|
||||
}
|
||||
@@ -399,7 +399,9 @@ async fn connect_via_http_proxy(
|
||||
stream.write_all(connect_req.as_bytes()).await?;
|
||||
|
||||
let mut buffer = [0u8; 4096];
|
||||
let n = stream.read(&mut buffer).await?;
|
||||
let n = tokio::time::timeout(UPSTREAM_DIAL_TIMEOUT, stream.read(&mut buffer))
|
||||
.await
|
||||
.map_err(|_| "upstream proxy CONNECT response timed out")??;
|
||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
||||
|
||||
if response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200") {
|
||||
@@ -409,6 +411,96 @@ async fn connect_via_http_proxy(
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract percent-decoded (username, password) from the upstream URL.
|
||||
///
|
||||
/// `url::Url::username()` / `Url::password()` return percent-encoded ASCII
|
||||
/// strings per the WHATWG spec. `build_proxy_url` on the producer side
|
||||
/// already percent-encodes the credentials with `urlencoding::encode`, so
|
||||
/// we must decode here — otherwise the upstream SOCKS5 / HTTP CONNECT
|
||||
/// receives `%40` instead of `@`, breaking RFC1929 user/password
|
||||
/// authentication or HTTP Basic-Auth
|
||||
fn upstream_userpass(upstream: &Url) -> (String, String) {
|
||||
let username = urlencoding::decode(upstream.username())
|
||||
.map(|cow| cow.into_owned())
|
||||
.unwrap_or_default();
|
||||
let password = urlencoding::decode(upstream.password().unwrap_or(""))
|
||||
.map(|cow| cow.into_owned())
|
||||
.unwrap_or_default();
|
||||
(username, password)
|
||||
}
|
||||
|
||||
/// Transparent AsyncRead/AsyncWrite wrapper that logs every read/write
|
||||
/// byte of the SOCKS5 handshake. Used only during the handshake — the
|
||||
/// inner stream is taken back via `into_inner` once the handshake
|
||||
/// completes, so the tunnel phase pays no overhead
|
||||
struct SocksHandshakeLogger<S> {
|
||||
inner: S,
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl<S> SocksHandshakeLogger<S> {
|
||||
fn new(inner: S, label: String) -> Self {
|
||||
Self { inner, label }
|
||||
}
|
||||
|
||||
fn into_inner(self) -> S {
|
||||
self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncRead + Unpin> AsyncRead for SocksHandshakeLogger<S> {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
let before = buf.filled().len();
|
||||
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
|
||||
if let Poll::Ready(Ok(())) = &result {
|
||||
let after = buf.filled().len();
|
||||
if after > before {
|
||||
let bytes = &buf.filled()[before..after];
|
||||
log::trace!(
|
||||
"[socks-handshake:{}] <- {} byte(s): {:02x?}",
|
||||
self.label,
|
||||
bytes.len(),
|
||||
bytes
|
||||
);
|
||||
} else {
|
||||
log::trace!("[socks-handshake:{}] <- EOF (peer closed)", self.label);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncWrite + Unpin> AsyncWrite for SocksHandshakeLogger<S> {
|
||||
fn poll_write(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
let result = Pin::new(&mut self.inner).poll_write(cx, buf);
|
||||
if let Poll::Ready(Ok(n)) = &result {
|
||||
log::trace!(
|
||||
"[socks-handshake:{}] -> {} byte(s): {:02x?}",
|
||||
self.label,
|
||||
n,
|
||||
&buf[..*n]
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.inner).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.inner).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_via_socks(
|
||||
socks_addr: &str,
|
||||
target_host: &str,
|
||||
@@ -416,7 +508,9 @@ async fn connect_via_socks(
|
||||
is_socks5: bool,
|
||||
auth: Option<(&str, &str)>,
|
||||
) -> Result<TcpStream, Box<dyn std::error::Error>> {
|
||||
let mut stream = TcpStream::connect(socks_addr).await?;
|
||||
let stream = tokio::time::timeout(UPSTREAM_DIAL_TIMEOUT, TcpStream::connect(socks_addr))
|
||||
.await
|
||||
.map_err(|_| format!("SOCKS upstream connect to {socks_addr} timed out"))??;
|
||||
|
||||
if is_socks5 {
|
||||
// SOCKS5 connection using async_socks5
|
||||
@@ -433,9 +527,52 @@ async fn connect_via_socks(
|
||||
password: pass.to_string(),
|
||||
});
|
||||
|
||||
connect(&mut stream, target, auth_info).await?;
|
||||
Ok(stream)
|
||||
let has_auth = auth_info.is_some();
|
||||
log::trace!(
|
||||
"[socks-handshake] dialing {} (target={}:{}, has_auth={})",
|
||||
socks_addr,
|
||||
target_host,
|
||||
target_port,
|
||||
has_auth
|
||||
);
|
||||
|
||||
// Disable Nagle so the kernel doesn't further delay/coalesce the
|
||||
// syscalls issued when BufStream flushes
|
||||
let _ = stream.set_nodelay(true);
|
||||
|
||||
// BufStream wrapping is required: async_socks5 calls write_u8 for every
|
||||
// single-byte SOCKS5 / RFC1929 field, and on a raw TcpStream each call
|
||||
// becomes its own TCP segment. Some upstream SOCKS5 implementations
|
||||
// treat such a "fragmented auth submission" as a misbehaving client
|
||||
// and silently FIN instead of returning an RFC1929 status. BufStream
|
||||
// coalesces those small writes into one syscall on flush — this is
|
||||
// the usage pattern shown in the async_socks5 README
|
||||
let label = format!("{socks_addr}->{target_host}:{target_port}");
|
||||
let logged = SocksHandshakeLogger::new(stream, label);
|
||||
let mut buffered = tokio::io::BufStream::new(logged);
|
||||
let handshake = tokio::time::timeout(
|
||||
UPSTREAM_DIAL_TIMEOUT,
|
||||
connect(&mut buffered, target, auth_info),
|
||||
)
|
||||
.await;
|
||||
// Unwrap the layered stream: BufStream → SocksHandshakeLogger → TcpStream
|
||||
let stream = buffered.into_inner().into_inner();
|
||||
match handshake {
|
||||
Ok(Ok(_)) => {
|
||||
log::trace!("[socks-handshake] handshake completed ok");
|
||||
Ok(stream)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::trace!("[socks-handshake] handshake failed: {:?}", e);
|
||||
Err(e.into())
|
||||
}
|
||||
Err(_) => {
|
||||
log::trace!("[socks-handshake] handshake timed out");
|
||||
Err("SOCKS5 upstream handshake timed out".into())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut stream = stream;
|
||||
// SOCKS4 - simplified implementation
|
||||
let ip: std::net::IpAddr = target_host.parse()?;
|
||||
|
||||
@@ -509,47 +646,20 @@ async fn handle_http_via_socks4(
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve target host to IP (SOCKS4 requires IP addresses)
|
||||
let target_ip = match tokio::net::lookup_host((target_host, target_port)).await {
|
||||
Ok(mut addrs) => {
|
||||
if let Some(addr) = addrs.next() {
|
||||
match addr.ip() {
|
||||
std::net::IpAddr::V4(ipv4) => ipv4.octets(),
|
||||
std::net::IpAddr::V6(_) => {
|
||||
log::error!("SOCKS4 does not support IPv6");
|
||||
let mut response = Response::new(Full::new(Bytes::from(
|
||||
"SOCKS4 does not support IPv6 addresses",
|
||||
)));
|
||||
*response.status_mut() = StatusCode::BAD_GATEWAY;
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::error!("Failed to resolve target host: {}", target_host);
|
||||
let mut response = Response::new(Full::new(Bytes::from(format!(
|
||||
"Failed to resolve target host: {}",
|
||||
target_host
|
||||
))));
|
||||
*response.status_mut() = StatusCode::BAD_GATEWAY;
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to resolve target host {}: {}", target_host, e);
|
||||
let mut response = Response::new(Full::new(Bytes::from(format!(
|
||||
"Failed to resolve target host: {}",
|
||||
e
|
||||
))));
|
||||
*response.status_mut() = StatusCode::BAD_GATEWAY;
|
||||
return Ok(response);
|
||||
}
|
||||
};
|
||||
|
||||
// Build SOCKS4 CONNECT request
|
||||
// Build a SOCKS4a CONNECT request. We deliberately do NOT resolve the target
|
||||
// hostname locally: tokio::net::lookup_host would call the HOST resolver
|
||||
// (getaddrinfo), leaking the destination domain to the host's DNS server and
|
||||
// defeating the per-profile proxy. SOCKS4a has the PROXY resolve the name —
|
||||
// send the sentinel IP 0.0.0.x (x != 0), then the NULL-terminated userid, then
|
||||
// the NULL-terminated hostname. (Most SOCKS4 proxies support 4a; a legacy
|
||||
// SOCKS4-only proxy without remote DNS cannot be used leak-free for plaintext
|
||||
// HTTP — prefer SOCKS5 there.)
|
||||
let mut socks_request = vec![0x04, 0x01]; // SOCKS4, CONNECT
|
||||
socks_request.extend_from_slice(&target_port.to_be_bytes());
|
||||
socks_request.extend_from_slice(&target_ip);
|
||||
socks_request.push(0); // NULL terminator for userid
|
||||
socks_request.extend_from_slice(&[0, 0, 0, 1]); // 0.0.0.1 => SOCKS4a remote-DNS marker
|
||||
socks_request.push(0); // empty userid, NULL-terminated
|
||||
socks_request.extend_from_slice(target_host.as_bytes()); // hostname for the proxy to resolve
|
||||
socks_request.push(0); // NULL-terminated hostname
|
||||
|
||||
// Send SOCKS4 CONNECT request
|
||||
if let Err(e) = socks_stream.write_all(&socks_request).await {
|
||||
@@ -1071,8 +1181,19 @@ fn build_reqwest_client_with_proxy(
|
||||
Proxy::http(upstream_url)?
|
||||
}
|
||||
"socks5" => {
|
||||
// For SOCKS5, reqwest supports it directly
|
||||
Proxy::all(upstream_url)?
|
||||
// Donut: force REMOTE (proxy-side) DNS for plaintext HTTP over a SOCKS5
|
||||
// upstream. reqwest maps the bare `socks5` scheme to DnsResolve::Local,
|
||||
// which resolves the destination hostname on the HOST (getaddrinfo) BEFORE
|
||||
// connecting — leaking the destination domain to the host's DNS resolver
|
||||
// and defeating the per-profile proxy. The `socks5h` scheme maps to
|
||||
// DnsResolve::Proxy, so the proxy resolves the hostname and nothing leaks.
|
||||
// (The CONNECT/HTTPS path already does remote DNS via connect_via_socks's
|
||||
// AddrKind::Domain.)
|
||||
let remote_dns_url = match upstream_url.strip_prefix("socks5://") {
|
||||
Some(rest) => format!("socks5h://{rest}"),
|
||||
None => upstream_url.to_string(),
|
||||
};
|
||||
Proxy::all(remote_dns_url)?
|
||||
}
|
||||
"socks4" => {
|
||||
// SOCKS4 is handled manually in handle_http_via_socks4
|
||||
@@ -1156,7 +1277,16 @@ pub async fn handle_proxy_connection(
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("CONNECT tunnel ended with error: {e}");
|
||||
let msg = e.to_string();
|
||||
if let Some(suppressed) = log_throttle(&msg) {
|
||||
if suppressed > 0 {
|
||||
log::warn!(
|
||||
"CONNECT tunnel ended with error: {msg} ({suppressed} more suppressed in last 30s)"
|
||||
);
|
||||
} else {
|
||||
log::warn!("CONNECT tunnel ended with error: {msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1263,10 +1393,19 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
|
||||
log::info!("Successfully bound to port {}", actual_port);
|
||||
|
||||
// Update config with actual port and local_url
|
||||
// Protocol served to the browser: "socks5" (Wayfern) or "http" (default).
|
||||
let local_protocol = config.local_protocol_or_default();
|
||||
let serve_socks5 = local_protocol == "socks5";
|
||||
|
||||
// Update config with actual port and local_url (scheme matches the protocol
|
||||
// we serve, so the parent's readiness check and any consumer see the truth)
|
||||
let mut updated_config = config.clone();
|
||||
updated_config.local_port = Some(actual_port);
|
||||
updated_config.local_url = Some(format!("http://127.0.0.1:{}", actual_port));
|
||||
updated_config.local_url = Some(format!(
|
||||
"{}://127.0.0.1:{}",
|
||||
if serve_socks5 { "socks5" } else { "http" },
|
||||
actual_port
|
||||
));
|
||||
|
||||
if !crate::proxy_storage::update_proxy_config(&updated_config) {
|
||||
log::error!("Failed to update proxy config");
|
||||
@@ -1366,6 +1505,48 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
}
|
||||
});
|
||||
|
||||
// Self-reaping supervisor. The worker is a detached process that outlives the
|
||||
// GUI, so it cannot rely on the GUI's in-memory death-monitor (which is lost
|
||||
// when the GUI restarts). Once the GUI records the browser PID this worker
|
||||
// serves, poll it and exit when that browser is gone — never while it is
|
||||
// alive, and never before a PID is recorded (covers the launch window and
|
||||
// pre-upgrade configs lacking the field). A 2-miss debounce avoids exiting on
|
||||
// a transient sysinfo false-negative under load / sleep-wake.
|
||||
{
|
||||
let watch_id = config.id.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(15));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut consecutive_misses: u32 = 0;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match crate::proxy_storage::get_proxy_config(&watch_id) {
|
||||
Some(cfg) => match cfg.browser_pid {
|
||||
Some(bpid) if bpid != 0 => {
|
||||
if crate::proxy_storage::is_process_running(bpid) {
|
||||
consecutive_misses = 0;
|
||||
} else {
|
||||
consecutive_misses += 1;
|
||||
if consecutive_misses >= 2 {
|
||||
log::info!("Browser PID {bpid} for config {watch_id} is gone; worker exiting");
|
||||
crate::proxy_storage::delete_proxy_config(&watch_id);
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// No browser PID recorded yet (launch window / old config): keep running.
|
||||
_ => consecutive_misses = 0,
|
||||
},
|
||||
// Our own config was removed (e.g. GUI stopped us): nothing to serve.
|
||||
None => {
|
||||
log::info!("Proxy config {watch_id} was removed; worker exiting");
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let bypass_matcher = BypassMatcher::new(&config.bypass_rules);
|
||||
let blocklist_matcher = if let Some(ref path) = config.blocklist_file {
|
||||
match BlocklistMatcher::from_file(path) {
|
||||
@@ -1379,17 +1560,40 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
BlocklistMatcher::new()
|
||||
};
|
||||
|
||||
// Bound concurrent connection handlers. A client retry-storm (e.g. a browser
|
||||
// hammering CONNECT requests while DNS is failing) must not spawn unbounded
|
||||
// tasks,
|
||||
// each of which parks a Tokio blocking thread inside getaddrinfo — that is
|
||||
// what exhausted the resolver pool and pegged the CPU on long-lived workers.
|
||||
// A real browser never approaches this ceiling; waiting for a permit
|
||||
// backpressures a storm instead of amplifying it.
|
||||
let conn_semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_CONNECTIONS));
|
||||
|
||||
// Keep the runtime alive with an infinite loop
|
||||
// This ensures the process doesn't exit even if there are no active connections
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, _peer_addr)) => {
|
||||
// The semaphore is never closed, so acquire cannot fail.
|
||||
let permit = conn_semaphore
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.expect("connection semaphore is never closed");
|
||||
let upstream = upstream_url.clone();
|
||||
let matcher = bypass_matcher.clone();
|
||||
let blocker = blocklist_matcher.clone();
|
||||
tokio::task::spawn(async move {
|
||||
handle_proxy_connection(stream, upstream, matcher, blocker).await;
|
||||
});
|
||||
if serve_socks5 {
|
||||
tokio::task::spawn(async move {
|
||||
let _permit = permit;
|
||||
crate::socks5_local::handle_socks5_connection(stream, upstream, matcher, blocker).await;
|
||||
});
|
||||
} else {
|
||||
tokio::task::spawn(async move {
|
||||
let _permit = permit;
|
||||
handle_proxy_connection(stream, upstream, matcher, blocker).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error accepting connection: {:?}", e);
|
||||
@@ -1452,7 +1656,7 @@ async fn handle_connect_from_buffer(
|
||||
tracker.record_request(&domain, 0, 0);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
log::debug!(
|
||||
"CONNECT {}:{} (upstream={})",
|
||||
target_host,
|
||||
target_port,
|
||||
@@ -1460,29 +1664,186 @@ async fn handle_connect_from_buffer(
|
||||
);
|
||||
|
||||
// Connect to target (directly or via upstream proxy).
|
||||
// Returns a BoxedAsyncStream so all upstream types (plain TCP, SOCKS,
|
||||
// Shadowsocks) share the same bidirectional-copy tunnel code below.
|
||||
let target_stream = connect_to_target_via_upstream(
|
||||
target_host,
|
||||
target_port,
|
||||
upstream_url.as_deref(),
|
||||
&bypass_matcher,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Send 200 Connection Established response to client
|
||||
// CRITICAL: Must flush after writing to ensure response is sent before tunneling
|
||||
client_stream
|
||||
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||
.await?;
|
||||
client_stream.flush().await?;
|
||||
|
||||
log::trace!("Sent 200 Connection Established response, starting tunnel");
|
||||
|
||||
tunnel_streams(client_stream, target_stream, domain).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upper bound on concurrent connection handlers per worker. A real browser
|
||||
/// never holds anywhere near this many simultaneous tunnels; the cap stops a
|
||||
/// client retry-storm from spawning unbounded tasks (each of which parks a
|
||||
/// Tokio blocking thread inside getaddrinfo).
|
||||
const MAX_CONCURRENT_CONNECTIONS: usize = 512;
|
||||
|
||||
/// Connect timeout for the direct (no-upstream) dial path. Bounds a wedged
|
||||
/// `getaddrinfo` so a broken resolver can't park a blocking thread for the
|
||||
/// full OS timeout.
|
||||
const DIRECT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Overall timeout for dialing an UPSTREAM proxy (TCP connect + CONNECT/SOCKS/SS
|
||||
/// handshake). Without it, an upstream that accepts TCP but stalls before
|
||||
/// replying hangs the worker task forever and holds a connection slot; under
|
||||
/// load (e.g. two profiles sharing one proxy) the slots exhaust and the browser
|
||||
/// sees `ERR_PROXY_CONNECTION_FAILED` until the profile is restarted (issue
|
||||
/// #439). A bounded dial fails fast and releases the slot.
|
||||
const UPSTREAM_DIAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
/// Per-host failure state (last failure instant, consecutive failure count) for
|
||||
/// the direct dial path. Process-global — each worker is its own process.
|
||||
fn direct_dial_failures() -> &'static Mutex<HashMap<String, (std::time::Instant, u32)>> {
|
||||
static M: OnceLock<Mutex<HashMap<String, (std::time::Instant, u32)>>> = OnceLock::new();
|
||||
M.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// If `host` is inside its failure backoff window, return the remaining time so
|
||||
/// the caller can short-circuit without a fresh getaddrinfo/connect. Never
|
||||
/// mutates state, so the window always expires and the path self-heals once
|
||||
/// DNS recovers.
|
||||
fn direct_backoff_remaining(host: &str) -> Option<std::time::Duration> {
|
||||
let map = direct_dial_failures();
|
||||
let guard = map.lock().unwrap();
|
||||
let (last, fails) = guard.get(host).copied()?;
|
||||
// Exponential window capped at 30s: 2, 4, 8, 16, 30, 30, ...
|
||||
let window = std::time::Duration::from_secs((1u64 << fails.min(5)).min(30));
|
||||
let elapsed = last.elapsed();
|
||||
if elapsed < window {
|
||||
Some(window - elapsed)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a direct-dial failure for `host`, growing its backoff window.
|
||||
fn direct_backoff_record(host: &str) {
|
||||
let map = direct_dial_failures();
|
||||
let mut guard = map.lock().unwrap();
|
||||
// Bound memory against a page that emits many distinct failing hosts.
|
||||
if guard.len() > 2048 {
|
||||
guard.retain(|_, (last, _)| last.elapsed() < std::time::Duration::from_secs(60));
|
||||
}
|
||||
let entry = guard
|
||||
.entry(host.to_string())
|
||||
.or_insert_with(|| (std::time::Instant::now(), 0));
|
||||
entry.0 = std::time::Instant::now();
|
||||
entry.1 = entry.1.saturating_add(1);
|
||||
}
|
||||
|
||||
/// Clear `host`'s failure state after a successful dial.
|
||||
fn direct_backoff_clear(host: &str) {
|
||||
direct_dial_failures().lock().unwrap().remove(host);
|
||||
}
|
||||
|
||||
/// Dial a target directly (no upstream) with a connect timeout and per-host
|
||||
/// failure backoff. This is the server-side counterpart to the browser's
|
||||
/// instant client-side retry: when a host's DNS/connect is failing (e.g. the
|
||||
/// macOS resolver wedges after sleep/wake), repeated CONNECT requests
|
||||
/// short-circuit
|
||||
/// here instead of each spawning a fresh blocking getaddrinfo — which is what
|
||||
/// let a retry-storm exhaust the blocking thread pool and peg the CPU.
|
||||
async fn dial_direct(host: &str, port: u16) -> Result<TcpStream, Box<dyn std::error::Error>> {
|
||||
if let Some(remaining) = direct_backoff_remaining(host) {
|
||||
return Err(
|
||||
format!(
|
||||
"skipping direct dial to {host}: backing off ~{}s after repeated connect failures",
|
||||
remaining.as_secs().max(1)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
match tokio::time::timeout(DIRECT_CONNECT_TIMEOUT, TcpStream::connect((host, port))).await {
|
||||
Ok(Ok(stream)) => {
|
||||
let _ = stream.set_nodelay(true);
|
||||
direct_backoff_clear(host);
|
||||
Ok(stream)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
direct_backoff_record(host);
|
||||
Err(e.into())
|
||||
}
|
||||
Err(_) => {
|
||||
direct_backoff_record(host);
|
||||
Err(
|
||||
format!(
|
||||
"direct connect to {host}:{port} timed out after {}s",
|
||||
DIRECT_CONNECT_TIMEOUT.as_secs()
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rate-limit a repetitive log line keyed by `key`: returns `Some(suppressed)`
|
||||
/// when the caller should emit (first time or after a 30s window, with the
|
||||
/// count dropped since the last emit), or `None` to skip. Stops a connect/DNS
|
||||
/// storm from writing the same WARN millions of times (the line that grew
|
||||
/// worker logs to 100MB).
|
||||
pub(crate) fn log_throttle(key: &str) -> Option<u64> {
|
||||
fn throttle_map() -> &'static Mutex<HashMap<String, (std::time::Instant, u64)>> {
|
||||
static M: OnceLock<Mutex<HashMap<String, (std::time::Instant, u64)>>> = OnceLock::new();
|
||||
M.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
let map = throttle_map();
|
||||
let mut guard = map.lock().unwrap();
|
||||
if guard.len() > 2048 {
|
||||
guard.retain(|_, (last, _)| last.elapsed() < std::time::Duration::from_secs(60));
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
match guard.get_mut(key) {
|
||||
Some((last, suppressed)) => {
|
||||
if now.duration_since(*last) >= std::time::Duration::from_secs(30) {
|
||||
let dropped = *suppressed;
|
||||
*last = now;
|
||||
*suppressed = 0;
|
||||
Some(dropped)
|
||||
} else {
|
||||
*suppressed += 1;
|
||||
None
|
||||
}
|
||||
}
|
||||
None => {
|
||||
guard.insert(key.to_string(), (now, 0));
|
||||
Some(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Establish a stream to `target_host:target_port`, either directly or through
|
||||
/// the configured upstream proxy. Shared by the HTTP CONNECT path and the
|
||||
/// local SOCKS5 server so every upstream type (direct, HTTP/HTTPS CONNECT,
|
||||
/// SOCKS4/5, Shadowsocks) is dialed in exactly one place. Returns a
|
||||
/// `BoxedAsyncStream` so the caller can tunnel over any upstream uniformly.
|
||||
pub(crate) async fn connect_to_target_via_upstream(
|
||||
target_host: &str,
|
||||
target_port: u16,
|
||||
upstream_url: Option<&str>,
|
||||
bypass_matcher: &BypassMatcher,
|
||||
) -> Result<BoxedAsyncStream, Box<dyn std::error::Error>> {
|
||||
let should_bypass = bypass_matcher.should_bypass(target_host);
|
||||
// Helper: configure outbound TCP to match browser TCP fingerprint
|
||||
let configure_tcp = |stream: &TcpStream| {
|
||||
let _ = stream.set_nodelay(true);
|
||||
};
|
||||
let target_stream: BoxedAsyncStream = match upstream_url.as_ref() {
|
||||
None => {
|
||||
let s = TcpStream::connect((target_host, target_port)).await?;
|
||||
configure_tcp(&s);
|
||||
Box::new(s)
|
||||
}
|
||||
Some(url) if url == "DIRECT" => {
|
||||
let s = TcpStream::connect((target_host, target_port)).await?;
|
||||
configure_tcp(&s);
|
||||
Box::new(s)
|
||||
}
|
||||
_ if should_bypass => {
|
||||
let s = TcpStream::connect((target_host, target_port)).await?;
|
||||
configure_tcp(&s);
|
||||
Box::new(s)
|
||||
}
|
||||
let target_stream: BoxedAsyncStream = match upstream_url {
|
||||
None | Some("DIRECT") => Box::new(dial_direct(target_host, target_port).await?),
|
||||
_ if should_bypass => Box::new(dial_direct(target_host, target_port).await?),
|
||||
Some(upstream_url_str) => {
|
||||
let upstream = Url::parse(upstream_url_str)?;
|
||||
let scheme = upstream.scheme();
|
||||
@@ -1491,7 +1852,14 @@ async fn handle_connect_from_buffer(
|
||||
"http" | "https" => {
|
||||
let proxy_host = upstream.host_str().unwrap_or("127.0.0.1");
|
||||
let proxy_port = upstream.port().unwrap_or(8080);
|
||||
let mut proxy_stream = TcpStream::connect((proxy_host, proxy_port)).await?;
|
||||
let mut proxy_stream = tokio::time::timeout(
|
||||
UPSTREAM_DIAL_TIMEOUT,
|
||||
TcpStream::connect((proxy_host, proxy_port)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
format!("upstream proxy connect to {proxy_host}:{proxy_port} timed out")
|
||||
})??;
|
||||
configure_tcp(&proxy_stream);
|
||||
|
||||
let mut connect_req = format!(
|
||||
@@ -1499,10 +1867,9 @@ async fn handle_connect_from_buffer(
|
||||
target_host, target_port, target_host, target_port
|
||||
);
|
||||
|
||||
if !upstream.username().is_empty() {
|
||||
let (username, password) = upstream_userpass(&upstream);
|
||||
if !username.is_empty() {
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
let username = upstream.username();
|
||||
let password = upstream.password().unwrap_or("");
|
||||
let auth = general_purpose::STANDARD.encode(format!("{}:{}", username, password));
|
||||
connect_req.push_str(&format!("Proxy-Authorization: Basic {}\r\n", auth));
|
||||
}
|
||||
@@ -1512,7 +1879,9 @@ async fn handle_connect_from_buffer(
|
||||
proxy_stream.write_all(connect_req.as_bytes()).await?;
|
||||
|
||||
let mut buffer = [0u8; 4096];
|
||||
let n = proxy_stream.read(&mut buffer).await?;
|
||||
let n = tokio::time::timeout(UPSTREAM_DIAL_TIMEOUT, proxy_stream.read(&mut buffer))
|
||||
.await
|
||||
.map_err(|_| "upstream proxy CONNECT response timed out")??;
|
||||
let response_full = String::from_utf8_lossy(&buffer[..n]).to_string();
|
||||
let status_line = response_full.lines().next().unwrap_or("").to_string();
|
||||
|
||||
@@ -1560,19 +1929,15 @@ async fn handle_connect_from_buffer(
|
||||
let socks_port = upstream.port().unwrap_or(1080);
|
||||
let socks_addr = format!("{}:{}", socks_host, socks_port);
|
||||
|
||||
let username = upstream.username();
|
||||
let password = upstream.password().unwrap_or("");
|
||||
let (username, password) = upstream_userpass(&upstream);
|
||||
let auth = (!username.is_empty()).then_some((username.as_str(), password.as_str()));
|
||||
|
||||
let stream = connect_via_socks(
|
||||
&socks_addr,
|
||||
target_host,
|
||||
target_port,
|
||||
scheme == "socks5",
|
||||
if !username.is_empty() {
|
||||
Some((username, password))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
auth,
|
||||
)
|
||||
.await?;
|
||||
Box::new(stream)
|
||||
@@ -1615,12 +1980,16 @@ async fn handle_connect_from_buffer(
|
||||
let target_addr =
|
||||
shadowsocks::relay::Address::DomainNameAddress(target_host.to_string(), target_port);
|
||||
|
||||
let stream = shadowsocks::relay::tcprelay::proxy_stream::ProxyClientStream::connect(
|
||||
context,
|
||||
&svr_cfg,
|
||||
target_addr,
|
||||
let stream = tokio::time::timeout(
|
||||
UPSTREAM_DIAL_TIMEOUT,
|
||||
shadowsocks::relay::tcprelay::proxy_stream::ProxyClientStream::connect(
|
||||
context,
|
||||
&svr_cfg,
|
||||
target_addr,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "Shadowsocks connection timed out".to_string())?
|
||||
.map_err(|e| format!("Shadowsocks connection failed: {e}"))?;
|
||||
|
||||
Box::new(stream)
|
||||
@@ -1632,20 +2001,18 @@ async fn handle_connect_from_buffer(
|
||||
}
|
||||
};
|
||||
|
||||
// TCP_NODELAY is set per-stream where applicable (TcpStream paths).
|
||||
// For encrypted streams (Shadowsocks), the underlying TCP connection
|
||||
// is managed by the library and nodelay is handled internally.
|
||||
Ok(target_stream)
|
||||
}
|
||||
|
||||
// Send 200 Connection Established response to client
|
||||
// CRITICAL: Must flush after writing to ensure response is sent before tunneling
|
||||
client_stream
|
||||
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||
.await?;
|
||||
client_stream.flush().await?;
|
||||
|
||||
log::trace!("Sent 200 Connection Established response, starting tunnel");
|
||||
|
||||
// Now tunnel data bidirectionally with counting
|
||||
/// Bidirectionally relay `client_stream` <-> `target_stream` until either side
|
||||
/// closes, counting bytes for traffic stats and attributing them to `domain`.
|
||||
/// The caller is responsible for having already sent any protocol-specific
|
||||
/// success reply (HTTP `200` or SOCKS5 reply) before calling this.
|
||||
pub(crate) async fn tunnel_streams(
|
||||
client_stream: TcpStream,
|
||||
target_stream: BoxedAsyncStream,
|
||||
domain: String,
|
||||
) {
|
||||
// Wrap streams to count bytes transferred
|
||||
let counting_client = CountingStream::new(client_stream);
|
||||
let counting_target = CountingStream::new(target_stream);
|
||||
@@ -1708,8 +2075,6 @@ async fn handle_connect_from_buffer(
|
||||
if let Some(tracker) = get_traffic_tracker() {
|
||||
tracker.update_domain_bytes(&domain, final_sent, final_recv);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1717,6 +2082,61 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
/// Build an upstream URL with `urlencoding::encode`-d user/pass,
|
||||
/// mirroring what `proxy_manager::build_proxy_url` actually emits
|
||||
fn parse_encoded_upstream(scheme: &str, user: &str, pass: &str) -> Url {
|
||||
let s = format!(
|
||||
"{}://{}:{}@127.0.0.1:1080",
|
||||
scheme,
|
||||
urlencoding::encode(user),
|
||||
urlencoding::encode(pass),
|
||||
);
|
||||
Url::parse(&s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_userpass_handles_plain_ascii() {
|
||||
let u = parse_encoded_upstream("socks5", "alice", "secret123");
|
||||
assert_eq!(upstream_userpass(&u), ("alice".into(), "secret123".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_userpass_decodes_special_chars() {
|
||||
// These characters all get percent-encoded by build_proxy_url before
|
||||
// landing in the URL, and must be decoded back to the original literal
|
||||
// before being handed off to the upstream
|
||||
let cases = [
|
||||
("alice", "p@ssw0rd"),
|
||||
("alice", "p:assw0rd"),
|
||||
("alice", "p ass word"),
|
||||
("alice", "abc/d+e=f"),
|
||||
("alice", "100%off!"),
|
||||
("alice", "测试密码"),
|
||||
("u@name", "v@lue"),
|
||||
];
|
||||
for (user, pass) in cases {
|
||||
let u = parse_encoded_upstream("socks5", user, pass);
|
||||
assert_eq!(
|
||||
upstream_userpass(&u),
|
||||
(user.to_string(), pass.to_string()),
|
||||
"decode failed: user={user:?} pass={pass:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_userpass_empty_when_no_credentials() {
|
||||
let u = Url::parse("socks5://127.0.0.1:1080").unwrap();
|
||||
assert_eq!(upstream_userpass(&u), (String::new(), String::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_userpass_handles_username_only() {
|
||||
let s = format!("socks5://{}@127.0.0.1:1080", urlencoding::encode("u@name"));
|
||||
let u = Url::parse(&s).unwrap();
|
||||
assert_eq!(upstream_userpass(&u), ("u@name".into(), String::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blocklist_exact_match() {
|
||||
let mut matcher = BlocklistMatcher::new();
|
||||
|
||||
@@ -16,6 +16,19 @@ pub struct ProxyConfig {
|
||||
pub bypass_rules: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub blocklist_file: Option<String>,
|
||||
/// Protocol the local worker serves to the browser: "http" (default, used
|
||||
/// by Camoufox/Firefox) or "socks5" (used by Wayfern/Chromium so QUIC and
|
||||
/// WebRTC UDP can be proxied without leaking the real IP). Independent of
|
||||
/// `upstream_url`, which is the real upstream proxy/VPN this worker dials.
|
||||
#[serde(default)]
|
||||
pub local_protocol: Option<String>,
|
||||
/// PID of the browser process this worker serves, recorded by the GUI after
|
||||
/// launch. The detached worker watches this and self-terminates when the
|
||||
/// browser dies, so it dies with its browser even if the GUI has exited or
|
||||
/// restarted. `None` until launch completes (the worker keeps running while
|
||||
/// it is `None`).
|
||||
#[serde(default)]
|
||||
pub browser_pid: Option<u32>,
|
||||
}
|
||||
|
||||
impl ProxyConfig {
|
||||
@@ -30,6 +43,8 @@ impl ProxyConfig {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
browser_pid: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +62,20 @@ impl ProxyConfig {
|
||||
self.blocklist_file = blocklist_file;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_local_protocol(mut self, local_protocol: Option<String>) -> Self {
|
||||
self.local_protocol = local_protocol;
|
||||
self
|
||||
}
|
||||
|
||||
/// "socks5" or "http" (default). Lowercased for case-insensitive matching.
|
||||
pub fn local_protocol_or_default(&self) -> String {
|
||||
self
|
||||
.local_protocol
|
||||
.as_deref()
|
||||
.unwrap_or("http")
|
||||
.to_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_storage_dir() -> PathBuf {
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
//! Local SOCKS5 server served to the browser (Wayfern/Chromium).
|
||||
//!
|
||||
//! The HTTP front-end (`proxy_server::handle_proxy_connection`) can only tunnel
|
||||
//! TCP, so QUIC and WebRTC — which are UDP — would be forced direct and leak the
|
||||
//! real IP. Serving SOCKS5 instead lets Chromium proxy UDP via SOCKS5 UDP
|
||||
//! ASSOCIATE (RFC 1928). TCP CONNECT reuses the exact same upstream-dial and
|
||||
//! tunnel code as the HTTP path, so every upstream type (direct, HTTP/HTTPS
|
||||
//! CONNECT, SOCKS4/5, Shadowsocks) behaves identically.
|
||||
//!
|
||||
//! UDP ASSOCIATE is leak-safe by construction: UDP is only relayed where it
|
||||
//! cannot expose the host IP — directly when there is no upstream proxy, or
|
||||
//! tunneled through a UDP-capable SOCKS5 upstream. For upstreams that cannot
|
||||
//! carry UDP (HTTP/HTTPS/SOCKS4/Shadowsocks, or a SOCKS5 upstream that refuses
|
||||
//! the association) the request is refused, so Chromium falls back to proxied
|
||||
//! TCP rather than sending UDP from the real IP.
|
||||
|
||||
use crate::proxy_server::{
|
||||
connect_to_target_via_upstream, tunnel_streams, BlocklistMatcher, BypassMatcher,
|
||||
};
|
||||
use crate::traffic_stats::get_traffic_tracker;
|
||||
use async_socks5::{AddrKind, Auth, SocksDatagram};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpStream, UdpSocket};
|
||||
use url::Url;
|
||||
|
||||
// SOCKS5 reply codes (RFC 1928 §6).
|
||||
const REP_SUCCEEDED: u8 = 0x00;
|
||||
const REP_GENERAL_FAILURE: u8 = 0x01;
|
||||
const REP_NOT_ALLOWED: u8 = 0x02;
|
||||
const REP_COMMAND_NOT_SUPPORTED: u8 = 0x07;
|
||||
|
||||
// SOCKS5 commands (RFC 1928 §4).
|
||||
const CMD_CONNECT: u8 = 0x01;
|
||||
const CMD_UDP_ASSOCIATE: u8 = 0x03;
|
||||
|
||||
// Max UDP datagram payload; sized for a full 64 KiB datagram plus header slack.
|
||||
const UDP_BUF: usize = 65_536;
|
||||
|
||||
/// How a UDP ASSOCIATE request must be served for a given upstream so the real
|
||||
/// IP never leaks.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum UdpMode {
|
||||
/// No upstream proxy: relay UDP directly (the host IP is the profile's IP,
|
||||
/// so there is nothing to hide).
|
||||
Direct,
|
||||
/// SOCKS5 upstream: attempt SOCKS5 UDP ASSOCIATE against it. Tunnels UDP if
|
||||
/// the upstream grants it; refuses (no leak) if it does not.
|
||||
Socks5Upstream,
|
||||
/// Upstream that cannot carry UDP (HTTP/HTTPS/SOCKS4/Shadowsocks): refuse so
|
||||
/// Chromium falls back to proxied TCP instead of leaking UDP.
|
||||
Refuse,
|
||||
}
|
||||
|
||||
/// Decide the leak-safe UDP policy for an upstream URL.
|
||||
fn udp_mode(upstream_url: Option<&str>) -> UdpMode {
|
||||
match upstream_url {
|
||||
None => UdpMode::Direct,
|
||||
Some("DIRECT") => UdpMode::Direct,
|
||||
Some(url) => match Url::parse(url).ok().map(|u| u.scheme().to_lowercase()) {
|
||||
Some(scheme) if scheme == "socks5" => UdpMode::Socks5Upstream,
|
||||
// http / https / socks4 / ss / shadowsocks / anything else: TCP-only.
|
||||
_ => UdpMode::Refuse,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `0.0.0.0:0` — used for BND fields in replies where the bound address is
|
||||
/// irrelevant to the client (e.g. CONNECT).
|
||||
fn unspecified() -> SocketAddr {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0)
|
||||
}
|
||||
|
||||
/// Handle one SOCKS5 client connection from the browser. Mirrors the spawn
|
||||
/// contract of `proxy_server::handle_proxy_connection`.
|
||||
pub async fn handle_socks5_connection(
|
||||
mut stream: TcpStream,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
blocklist_matcher: BlocklistMatcher,
|
||||
) {
|
||||
let _ = stream.set_nodelay(true);
|
||||
|
||||
if let Err(e) = negotiate_method(&mut stream).await {
|
||||
log::debug!("SOCKS5 method negotiation failed: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
let request = match read_request(&mut stream).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::debug!("SOCKS5 request parse failed: {e}");
|
||||
let _ = send_reply(&mut stream, REP_GENERAL_FAILURE, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match request.cmd {
|
||||
CMD_CONNECT => {
|
||||
handle_connect(
|
||||
stream,
|
||||
request.host,
|
||||
request.port,
|
||||
upstream_url,
|
||||
bypass_matcher,
|
||||
blocklist_matcher,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
CMD_UDP_ASSOCIATE => {
|
||||
handle_udp_associate(stream, upstream_url).await;
|
||||
}
|
||||
other => {
|
||||
log::debug!("SOCKS5 unsupported command {other:#04x}");
|
||||
let _ = send_reply(&mut stream, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the SOCKS5 greeting and select the no-auth method. The local proxy is
|
||||
/// loopback-only, so no authentication is required (Chromium offers no-auth).
|
||||
async fn negotiate_method(stream: &mut TcpStream) -> std::io::Result<()> {
|
||||
let mut head = [0u8; 2];
|
||||
stream.read_exact(&mut head).await?;
|
||||
if head[0] != 0x05 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"not a SOCKS5 greeting",
|
||||
));
|
||||
}
|
||||
let nmethods = head[1] as usize;
|
||||
let mut methods = vec![0u8; nmethods];
|
||||
stream.read_exact(&mut methods).await?;
|
||||
|
||||
if methods.contains(&0x00) {
|
||||
stream.write_all(&[0x05, 0x00]).await?;
|
||||
Ok(())
|
||||
} else {
|
||||
// No acceptable methods.
|
||||
let _ = stream.write_all(&[0x05, 0xFF]).await;
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"no no-auth method offered",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
struct Socks5Request {
|
||||
cmd: u8,
|
||||
host: String,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
/// Read a SOCKS5 request line: VER, CMD, RSV, ATYP, DST.ADDR, DST.PORT.
|
||||
async fn read_request(stream: &mut TcpStream) -> std::io::Result<Socks5Request> {
|
||||
let mut head = [0u8; 4];
|
||||
stream.read_exact(&mut head).await?;
|
||||
if head[0] != 0x05 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"bad SOCKS5 request version",
|
||||
));
|
||||
}
|
||||
let cmd = head[1];
|
||||
let atyp = head[3];
|
||||
let host = read_addr(stream, atyp).await?;
|
||||
let mut port = [0u8; 2];
|
||||
stream.read_exact(&mut port).await?;
|
||||
Ok(Socks5Request {
|
||||
cmd,
|
||||
host,
|
||||
port: u16::from_be_bytes(port),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a SOCKS5 address of the given type into a host string (an IP literal or
|
||||
/// a domain name; `connect_to_target_via_upstream` handles both).
|
||||
async fn read_addr(stream: &mut TcpStream, atyp: u8) -> std::io::Result<String> {
|
||||
match atyp {
|
||||
0x01 => {
|
||||
let mut b = [0u8; 4];
|
||||
stream.read_exact(&mut b).await?;
|
||||
Ok(Ipv4Addr::new(b[0], b[1], b[2], b[3]).to_string())
|
||||
}
|
||||
0x04 => {
|
||||
let mut b = [0u8; 16];
|
||||
stream.read_exact(&mut b).await?;
|
||||
Ok(Ipv6Addr::from(b).to_string())
|
||||
}
|
||||
0x03 => {
|
||||
let mut len = [0u8; 1];
|
||||
stream.read_exact(&mut len).await?;
|
||||
let mut domain = vec![0u8; len[0] as usize];
|
||||
stream.read_exact(&mut domain).await?;
|
||||
Ok(String::from_utf8_lossy(&domain).to_string())
|
||||
}
|
||||
other => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("unsupported SOCKS5 address type {other:#04x}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a SOCKS5 reply with the given code and bound address.
|
||||
async fn send_reply(stream: &mut TcpStream, rep: u8, bnd: SocketAddr) -> std::io::Result<()> {
|
||||
let mut resp = vec![0x05, rep, 0x00];
|
||||
push_addr(&mut resp, bnd);
|
||||
stream.write_all(&resp).await
|
||||
}
|
||||
|
||||
/// Append an ATYP + address + port to a SOCKS5 message buffer.
|
||||
fn push_addr(buf: &mut Vec<u8>, addr: SocketAddr) {
|
||||
match addr.ip() {
|
||||
IpAddr::V4(v4) => {
|
||||
buf.push(0x01);
|
||||
buf.extend_from_slice(&v4.octets());
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
buf.push(0x04);
|
||||
buf.extend_from_slice(&v6.octets());
|
||||
}
|
||||
}
|
||||
buf.extend_from_slice(&addr.port().to_be_bytes());
|
||||
}
|
||||
|
||||
/// SOCKS5 CONNECT: dial the target via the upstream and bidirectionally tunnel,
|
||||
/// reusing the same code path as the HTTP CONNECT proxy.
|
||||
async fn handle_connect(
|
||||
mut stream: TcpStream,
|
||||
host: String,
|
||||
port: u16,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
blocklist_matcher: BlocklistMatcher,
|
||||
) {
|
||||
if blocklist_matcher.is_blocked(&host) {
|
||||
log::debug!("[blocklist] Blocked SOCKS5 CONNECT to {host}");
|
||||
let _ = send_reply(&mut stream, REP_NOT_ALLOWED, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(tracker) = get_traffic_tracker() {
|
||||
tracker.record_request(&host, 0, 0);
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"SOCKS5 CONNECT {}:{} (upstream={})",
|
||||
host,
|
||||
port,
|
||||
upstream_url.as_deref().unwrap_or("DIRECT")
|
||||
);
|
||||
|
||||
// Resolve to the target stream, logging and dropping the (non-Send) dial
|
||||
// error inside the match arm so it is never held across the await below.
|
||||
let target = match connect_to_target_via_upstream(
|
||||
&host,
|
||||
port,
|
||||
upstream_url.as_deref(),
|
||||
&bypass_matcher,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(t) => Some(t),
|
||||
Err(e) => {
|
||||
let key = format!("socks5-connect:{host}:{port}");
|
||||
if let Some(suppressed) = crate::proxy_server::log_throttle(&key) {
|
||||
if suppressed > 0 {
|
||||
log::warn!(
|
||||
"SOCKS5 CONNECT to {host}:{port} failed: {e} ({suppressed} more suppressed in last 30s)"
|
||||
);
|
||||
} else {
|
||||
log::warn!("SOCKS5 CONNECT to {host}:{port} failed: {e}");
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let Some(target) = target else {
|
||||
let _ = send_reply(&mut stream, REP_GENERAL_FAILURE, unspecified()).await;
|
||||
return;
|
||||
};
|
||||
|
||||
if send_reply(&mut stream, REP_SUCCEEDED, unspecified())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
tunnel_streams(stream, target, host).await;
|
||||
}
|
||||
|
||||
/// SOCKS5 UDP ASSOCIATE, leak-safe per upstream (see [`UdpMode`]).
|
||||
///
|
||||
/// `control` is the TCP control connection; the UDP association lives exactly
|
||||
/// as long as it stays open (RFC 1928 §6), so the relay loop tears down when
|
||||
/// the browser closes it.
|
||||
async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option<String>) {
|
||||
let mode = udp_mode(upstream_url.as_deref());
|
||||
|
||||
if mode == UdpMode::Refuse {
|
||||
log::info!(
|
||||
"SOCKS5 UDP ASSOCIATE refused: upstream ({}) cannot carry UDP without leaking; Chromium will use proxied TCP",
|
||||
upstream_url.as_deref().unwrap_or("DIRECT")
|
||||
);
|
||||
let _ = send_reply(&mut control, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// The UDP relay socket the browser sends its datagrams to. Loopback-only.
|
||||
let relay = match UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to bind UDP relay socket: {e}");
|
||||
let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let relay_addr = match relay.local_addr() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read UDP relay addr: {e}");
|
||||
let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match mode {
|
||||
UdpMode::Direct => {
|
||||
// Bind the egress socket before replying so a failure surfaces as a
|
||||
// refusal (no half-open association).
|
||||
let out = match UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to bind UDP egress socket: {e}");
|
||||
let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if send_reply(&mut control, REP_SUCCEEDED, relay_addr)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
log::info!("SOCKS5 UDP ASSOCIATE (direct) relaying on {relay_addr}");
|
||||
run_udp_relay_direct(control, relay, out).await;
|
||||
}
|
||||
UdpMode::Socks5Upstream => {
|
||||
// Establish the upstream association FIRST; if the upstream refuses UDP,
|
||||
// refuse to the browser too (no leak).
|
||||
let upstream = upstream_url.as_deref().unwrap_or("");
|
||||
let datagram = match associate_upstream(upstream).await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
log::info!(
|
||||
"SOCKS5 upstream did not grant UDP ASSOCIATE ({e}); refusing so Chromium uses proxied TCP"
|
||||
);
|
||||
let _ = send_reply(&mut control, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if send_reply(&mut control, REP_SUCCEEDED, relay_addr)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
log::info!("SOCKS5 UDP ASSOCIATE (via SOCKS5 upstream) relaying on {relay_addr}");
|
||||
run_udp_relay_socks5(control, relay, datagram).await;
|
||||
}
|
||||
UdpMode::Refuse => unreachable!("handled above"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a SOCKS5 UDP association against the upstream proxy.
|
||||
async fn associate_upstream(
|
||||
upstream_url: &str,
|
||||
) -> Result<SocksDatagram<TcpStream>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let upstream = Url::parse(upstream_url)?;
|
||||
let host = upstream.host_str().unwrap_or("127.0.0.1");
|
||||
let port = upstream.port().unwrap_or(1080);
|
||||
let auth = if !upstream.username().is_empty() {
|
||||
Some(Auth {
|
||||
username: upstream.username().to_string(),
|
||||
password: upstream.password().unwrap_or("").to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let proxy_stream = TcpStream::connect((host, port)).await?;
|
||||
let bind_sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await?;
|
||||
// association_addr None => 0.0.0.0:0 (we accept replies from any peer).
|
||||
let datagram = SocksDatagram::associate(proxy_stream, bind_sock, auth, None::<AddrKind>).await?;
|
||||
Ok(datagram)
|
||||
}
|
||||
|
||||
/// Parsed SOCKS5 UDP datagram header (RFC 1928 §7): the destination and the
|
||||
/// offset at which the payload begins. Fragmented datagrams (FRAG != 0) are
|
||||
/// rejected by the caller.
|
||||
struct UdpHeader {
|
||||
frag: u8,
|
||||
dst: AddrKind,
|
||||
data_offset: usize,
|
||||
}
|
||||
|
||||
fn parse_udp_header(buf: &[u8]) -> Option<UdpHeader> {
|
||||
if buf.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let frag = buf[2];
|
||||
let atyp = buf[3];
|
||||
match atyp {
|
||||
0x01 => {
|
||||
if buf.len() < 10 {
|
||||
return None;
|
||||
}
|
||||
let ip = Ipv4Addr::new(buf[4], buf[5], buf[6], buf[7]);
|
||||
let port = u16::from_be_bytes([buf[8], buf[9]]);
|
||||
Some(UdpHeader {
|
||||
frag,
|
||||
dst: AddrKind::Ip(SocketAddr::new(IpAddr::V4(ip), port)),
|
||||
data_offset: 10,
|
||||
})
|
||||
}
|
||||
0x04 => {
|
||||
if buf.len() < 22 {
|
||||
return None;
|
||||
}
|
||||
let mut octets = [0u8; 16];
|
||||
octets.copy_from_slice(&buf[4..20]);
|
||||
let ip = Ipv6Addr::from(octets);
|
||||
let port = u16::from_be_bytes([buf[20], buf[21]]);
|
||||
Some(UdpHeader {
|
||||
frag,
|
||||
dst: AddrKind::Ip(SocketAddr::new(IpAddr::V6(ip), port)),
|
||||
data_offset: 22,
|
||||
})
|
||||
}
|
||||
0x03 => {
|
||||
let dlen = *buf.get(4)? as usize;
|
||||
let needed = 5 + dlen + 2;
|
||||
if buf.len() < needed {
|
||||
return None;
|
||||
}
|
||||
let domain = String::from_utf8_lossy(&buf[5..5 + dlen]).to_string();
|
||||
let port = u16::from_be_bytes([buf[5 + dlen], buf[6 + dlen]]);
|
||||
Some(UdpHeader {
|
||||
frag,
|
||||
dst: AddrKind::Domain(domain, port),
|
||||
data_offset: needed,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a SOCKS5 UDP response datagram (header + payload) to send back to the
|
||||
/// browser, naming `peer` as the source.
|
||||
fn build_udp_response(peer: SocketAddr, data: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![0x00, 0x00, 0x00]; // RSV(2) + FRAG(0)
|
||||
push_addr(&mut out, peer);
|
||||
out.extend_from_slice(data);
|
||||
out
|
||||
}
|
||||
|
||||
/// Direct UDP relay: browser <-> a plain egress UDP socket. Used only when
|
||||
/// there is no upstream proxy, so the host IP is the profile's own IP.
|
||||
async fn run_udp_relay_direct(mut control: TcpStream, relay: UdpSocket, out: UdpSocket) {
|
||||
let mut client_addr: Option<SocketAddr> = None;
|
||||
let mut from_client = vec![0u8; UDP_BUF];
|
||||
let mut from_target = vec![0u8; UDP_BUF];
|
||||
let mut ctrl_buf = [0u8; 256];
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Control connection closed => association ends.
|
||||
r = control.read(&mut ctrl_buf) => {
|
||||
match r {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {} // ignore any data on the control channel
|
||||
}
|
||||
}
|
||||
// Browser -> target.
|
||||
r = relay.recv_from(&mut from_client) => {
|
||||
let Ok((n, src)) = r else { break };
|
||||
client_addr = Some(src);
|
||||
let Some(header) = parse_udp_header(&from_client[..n]) else { continue };
|
||||
if header.frag != 0 {
|
||||
continue; // fragmentation unsupported
|
||||
}
|
||||
let payload = &from_client[header.data_offset..n];
|
||||
let dst = match resolve_addr(&header.dst).await {
|
||||
Some(d) => d,
|
||||
None => continue,
|
||||
};
|
||||
let _ = out.send_to(payload, dst).await;
|
||||
}
|
||||
// Target -> browser.
|
||||
r = out.recv_from(&mut from_target) => {
|
||||
let Ok((n, peer)) = r else { continue };
|
||||
if let Some(client) = client_addr {
|
||||
let resp = build_udp_response(peer, &from_target[..n]);
|
||||
let _ = relay.send_to(&resp, client).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// UDP relay tunneled through a SOCKS5 upstream that granted UDP ASSOCIATE.
|
||||
async fn run_udp_relay_socks5(
|
||||
mut control: TcpStream,
|
||||
relay: UdpSocket,
|
||||
datagram: SocksDatagram<TcpStream>,
|
||||
) {
|
||||
let mut client_addr: Option<SocketAddr> = None;
|
||||
let mut from_client = vec![0u8; UDP_BUF];
|
||||
let mut from_upstream = vec![0u8; UDP_BUF];
|
||||
let mut ctrl_buf = [0u8; 256];
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
r = control.read(&mut ctrl_buf) => {
|
||||
match r {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
// Browser -> upstream.
|
||||
r = relay.recv_from(&mut from_client) => {
|
||||
let Ok((n, src)) = r else { break };
|
||||
client_addr = Some(src);
|
||||
let Some(header) = parse_udp_header(&from_client[..n]) else { continue };
|
||||
if header.frag != 0 {
|
||||
continue;
|
||||
}
|
||||
let payload = from_client[header.data_offset..n].to_vec();
|
||||
let _ = datagram.send_to(&payload, header.dst).await;
|
||||
}
|
||||
// Upstream -> browser.
|
||||
r = datagram.recv_from(&mut from_upstream) => {
|
||||
let Ok((n, peer)) = r else { continue };
|
||||
if let Some(client) = client_addr {
|
||||
let resp = build_udp_response(addrkind_to_socketaddr(&peer), &from_upstream[..n]);
|
||||
let _ = relay.send_to(&resp, client).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a UDP destination to a concrete socket address for direct relay.
|
||||
async fn resolve_addr(addr: &AddrKind) -> Option<SocketAddr> {
|
||||
match addr {
|
||||
AddrKind::Ip(s) => Some(*s),
|
||||
AddrKind::Domain(domain, port) => tokio::net::lookup_host(format!("{domain}:{port}"))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|mut it| it.next()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort conversion of an upstream-reported source address into a
|
||||
/// `SocketAddr` for the response header. A domain (rare for UDP) collapses to
|
||||
/// `0.0.0.0:port`, which clients treat as "from the proxy".
|
||||
fn addrkind_to_socketaddr(addr: &AddrKind) -> SocketAddr {
|
||||
match addr {
|
||||
AddrKind::Ip(s) => *s,
|
||||
AddrKind::Domain(_, port) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), *port),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn udp_mode_direct_for_none_and_direct() {
|
||||
assert_eq!(udp_mode(None), UdpMode::Direct);
|
||||
assert_eq!(udp_mode(Some("DIRECT")), UdpMode::Direct);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udp_mode_socks5_upstream() {
|
||||
assert_eq!(
|
||||
udp_mode(Some("socks5://user:pass@1.2.3.4:1080")),
|
||||
UdpMode::Socks5Upstream
|
||||
);
|
||||
assert_eq!(
|
||||
udp_mode(Some("socks5://1.2.3.4:1080")),
|
||||
UdpMode::Socks5Upstream
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udp_mode_refuses_tcp_only_upstreams() {
|
||||
// HTTP/HTTPS CONNECT, SOCKS4, and Shadowsocks cannot carry UDP, so UDP
|
||||
// ASSOCIATE must be refused (Chromium then uses proxied TCP — no leak).
|
||||
assert_eq!(udp_mode(Some("http://1.2.3.4:8080")), UdpMode::Refuse);
|
||||
assert_eq!(udp_mode(Some("https://1.2.3.4:8080")), UdpMode::Refuse);
|
||||
assert_eq!(udp_mode(Some("socks4://1.2.3.4:1080")), UdpMode::Refuse);
|
||||
assert_eq!(
|
||||
udp_mode(Some("ss://aes-256-gcm:pw@1.2.3.4:8388")),
|
||||
UdpMode::Refuse
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_udp_header_ipv4() {
|
||||
// RSV RSV FRAG ATYP=1 1.2.3.4 :443 payload="hi"
|
||||
let buf = [0, 0, 0, 0x01, 1, 2, 3, 4, 0x01, 0xBB, b'h', b'i'];
|
||||
let h = parse_udp_header(&buf).expect("ipv4 header");
|
||||
assert_eq!(h.frag, 0);
|
||||
assert_eq!(h.data_offset, 10);
|
||||
assert_eq!(
|
||||
h.dst,
|
||||
AddrKind::Ip(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 443))
|
||||
);
|
||||
assert_eq!(&buf[h.data_offset..], b"hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_udp_header_domain() {
|
||||
// ATYP=3, len=3, "abc", port 8080, payload "x"
|
||||
let mut buf = vec![0, 0, 0, 0x03, 3, b'a', b'b', b'c', 0x1F, 0x90];
|
||||
buf.push(b'x');
|
||||
let h = parse_udp_header(&buf).expect("domain header");
|
||||
assert_eq!(h.dst, AddrKind::Domain("abc".to_string(), 8080));
|
||||
assert_eq!(&buf[h.data_offset..], b"x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_udp_header_rejects_truncated() {
|
||||
assert!(parse_udp_header(&[0, 0, 0]).is_none());
|
||||
assert!(parse_udp_header(&[0, 0, 0, 0x01, 1, 2]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_udp_response_prefixes_header() {
|
||||
let resp = build_udp_response(
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 53),
|
||||
b"data",
|
||||
);
|
||||
// RSV RSV FRAG ATYP=1 9.9.9.9 :53 "data"
|
||||
assert_eq!(
|
||||
resp,
|
||||
vec![0, 0, 0, 0x01, 9, 9, 9, 9, 0x00, 0x35, b'd', b'a', b't', b'a']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -294,7 +294,10 @@ impl SyncProgressTracker {
|
||||
|
||||
/// Check if sync is configured (cloud or self-hosted)
|
||||
pub fn is_sync_configured() -> bool {
|
||||
if crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync() {
|
||||
// Cloud backup is a plan capability. Every paid plan (incl. the future
|
||||
// "starter" tier) grants it, but gating on the capability — not just "is paid"
|
||||
// — keeps this correct if a plan without cloud backup is ever added.
|
||||
if crate::cloud_auth::CLOUD_AUTH.can_use_cloud_backup_sync() {
|
||||
return true;
|
||||
}
|
||||
let manager = SettingsManager::instance();
|
||||
@@ -1597,6 +1600,13 @@ impl SyncEngine {
|
||||
))
|
||||
})?;
|
||||
|
||||
// Keep the in-memory cache in sync with disk. Without this, get_stored_proxies
|
||||
// (which reads only the in-memory map) never sees the downloaded proxy until
|
||||
// restart, so check_for_missing_synced_entities/sync_proxy treat it as
|
||||
// missing every pass and re-download it forever. Mirrors download_group/
|
||||
// download_vpn/download_extension.
|
||||
proxy_manager.upsert_stored_proxy(proxy.clone());
|
||||
|
||||
// Emit event for UI update
|
||||
if let Some(_handle) = app_handle {
|
||||
let _ = events::emit("stored-proxies-changed", ());
|
||||
|
||||
@@ -29,24 +29,35 @@ pub enum SyncWorkItem {
|
||||
Tombstone(String, String),
|
||||
}
|
||||
|
||||
/// Where a subscription's sync token comes from, so reconnects can re-fetch a
|
||||
/// fresh one (tokens are short-lived, ~15 min).
|
||||
#[derive(Clone, Copy)]
|
||||
enum TokenSource {
|
||||
Cloud,
|
||||
SelfHosted,
|
||||
}
|
||||
|
||||
pub struct SyncSubscription {
|
||||
client: Client,
|
||||
base_url: String,
|
||||
token: String,
|
||||
source: TokenSource,
|
||||
running: Arc<AtomicBool>,
|
||||
work_tx: mpsc::UnboundedSender<SyncWorkItem>,
|
||||
}
|
||||
|
||||
impl SyncSubscription {
|
||||
pub fn new(
|
||||
fn new(
|
||||
base_url: String,
|
||||
token: String,
|
||||
source: TokenSource,
|
||||
work_tx: mpsc::UnboundedSender<SyncWorkItem>,
|
||||
) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
token,
|
||||
source,
|
||||
running: Arc::new(AtomicBool::new(false)),
|
||||
work_tx,
|
||||
}
|
||||
@@ -66,7 +77,7 @@ impl SyncSubscription {
|
||||
let Some(token) = token else {
|
||||
return Ok(None);
|
||||
};
|
||||
return Ok(Some(Self::new(url, token, work_tx)));
|
||||
return Ok(Some(Self::new(url, token, TokenSource::Cloud, work_tx)));
|
||||
}
|
||||
|
||||
// Fall back to self-hosted settings
|
||||
@@ -88,7 +99,12 @@ impl SyncSubscription {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(Self::new(server_url, token, work_tx)))
|
||||
Ok(Some(Self::new(
|
||||
server_url,
|
||||
token,
|
||||
TokenSource::SelfHosted,
|
||||
work_tx,
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
@@ -106,9 +122,10 @@ impl SyncSubscription {
|
||||
|
||||
let running = self.running.clone();
|
||||
let base_url = self.base_url.clone();
|
||||
let token = self.token.clone();
|
||||
let source = self.source;
|
||||
let work_tx = self.work_tx.clone();
|
||||
let client = self.client.clone();
|
||||
let mut token = self.token.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
while running.load(Ordering::SeqCst) {
|
||||
@@ -126,6 +143,20 @@ impl SyncSubscription {
|
||||
|
||||
if running.load(Ordering::SeqCst) {
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
// Refresh the sync token before reconnecting. The token may have
|
||||
// expired while the stream was open (tokens last ~15 min); reusing
|
||||
// the construction-time token otherwise produces an endless 401
|
||||
// reconnect loop until the app is restarted (issue #440).
|
||||
match Self::fetch_sync_token(source, &app_handle).await {
|
||||
Ok(Some(fresh)) => token = fresh,
|
||||
Ok(None) => {
|
||||
log::info!("Sync token no longer available; stopping subscription");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to refresh sync token: {e}; retrying with the current token");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +164,24 @@ impl SyncSubscription {
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch a current sync token from the same source the subscription was
|
||||
/// created from, so reconnects never reuse a stale (expired) token.
|
||||
async fn fetch_sync_token(
|
||||
source: TokenSource,
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> Result<Option<String>, String> {
|
||||
match source {
|
||||
TokenSource::Cloud => crate::cloud_auth::CLOUD_AUTH
|
||||
.get_or_refresh_sync_token()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to refresh cloud sync token: {e}")),
|
||||
TokenSource::SelfHosted => SettingsManager::instance()
|
||||
.get_sync_token(app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to refresh self-hosted sync token: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_and_listen(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
|
||||
@@ -4,8 +4,9 @@ use boringtun::x25519::{PublicKey, StaticSecret};
|
||||
use smoltcp::iface::{Config as IfaceConfig, Interface, SocketHandle, SocketSet};
|
||||
use smoltcp::phy::{Device, DeviceCapabilities, Medium, RxToken, TxToken};
|
||||
use smoltcp::socket::tcp::{Socket as TcpSocket, SocketBuffer};
|
||||
use smoltcp::socket::udp;
|
||||
use smoltcp::time::Instant as SmolInstant;
|
||||
use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, Ipv4Address};
|
||||
use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, IpEndpoint, Ipv4Address};
|
||||
use std::collections::VecDeque;
|
||||
use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -13,6 +14,58 @@ use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
const SMOLTCP_TCP_RX_BUF: usize = 65536;
|
||||
const SMOLTCP_TCP_TX_BUF: usize = 65536;
|
||||
const SMOLTCP_UDP_BUF: usize = 65536;
|
||||
|
||||
/// Parse an RFC 1928 §7 UDP request header. Returns the destination endpoint
|
||||
/// and the payload offset, or None if malformed, fragmented, or domain-typed.
|
||||
/// Only literal IPs are routed through the tunnel: resolving a domain on the
|
||||
/// host would leak DNS, and QUIC/WebRTC datagrams always carry literal IPs.
|
||||
fn parse_udp_datagram(buf: &[u8]) -> Option<(IpEndpoint, usize)> {
|
||||
if buf.len() < 4 || buf[2] != 0 {
|
||||
// too short, or FRAG != 0 (fragmentation unsupported)
|
||||
return None;
|
||||
}
|
||||
match buf[3] {
|
||||
0x01 => {
|
||||
if buf.len() < 10 {
|
||||
return None;
|
||||
}
|
||||
let ip = Ipv4Address::new(buf[4], buf[5], buf[6], buf[7]);
|
||||
let port = u16::from_be_bytes([buf[8], buf[9]]);
|
||||
Some((IpEndpoint::new(IpAddress::Ipv4(ip), port), 10))
|
||||
}
|
||||
0x04 => {
|
||||
if buf.len() < 22 {
|
||||
return None;
|
||||
}
|
||||
let mut o = [0u8; 16];
|
||||
o.copy_from_slice(&buf[4..20]);
|
||||
let ip = smoltcp::wire::Ipv6Address::from(o);
|
||||
let port = u16::from_be_bytes([buf[20], buf[21]]);
|
||||
Some((IpEndpoint::new(IpAddress::Ipv6(ip), port), 22))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a tunnel-received datagram in an RFC 1928 §7 UDP reply header naming
|
||||
/// `src` as the origin, for delivery back to the browser's relay socket.
|
||||
fn build_udp_datagram(src: IpEndpoint, payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![0x00, 0x00, 0x00]; // RSV(2) + FRAG(0)
|
||||
match src.addr {
|
||||
IpAddress::Ipv4(v4) => {
|
||||
out.push(0x01);
|
||||
out.extend_from_slice(&v4.octets());
|
||||
}
|
||||
IpAddress::Ipv6(v6) => {
|
||||
out.push(0x04);
|
||||
out.extend_from_slice(&v6.octets());
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&src.port.to_be_bytes());
|
||||
out.extend_from_slice(payload);
|
||||
out
|
||||
}
|
||||
|
||||
struct WgDevice {
|
||||
tunn: Arc<Mutex<Box<Tunn>>>,
|
||||
@@ -432,6 +485,15 @@ impl WireGuardSocks5Server {
|
||||
|
||||
let mut sockets = SocketSet::new(vec![]);
|
||||
|
||||
// A live SOCKS5 UDP ASSOCIATE: the loopback relay socket the browser sends
|
||||
// datagrams to, and the browser's learned source address. The tunnel-side
|
||||
// smoltcp UDP socket lives in `sockets`, keyed by the connection's
|
||||
// (repurposed) `smol_handle`.
|
||||
struct UdpAssoc {
|
||||
relay: UdpSocket,
|
||||
client_addr: Option<SocketAddr>,
|
||||
}
|
||||
|
||||
struct Connection {
|
||||
smol_handle: SocketHandle,
|
||||
tcp_stream: TcpStream,
|
||||
@@ -440,6 +502,7 @@ impl WireGuardSocks5Server {
|
||||
greeting_done: bool,
|
||||
read_buf: Vec<u8>,
|
||||
dest_addr: Option<SocketAddr>,
|
||||
udp: Option<UdpAssoc>,
|
||||
}
|
||||
|
||||
let mut connections: Vec<Connection> = Vec::new();
|
||||
@@ -463,6 +526,7 @@ impl WireGuardSocks5Server {
|
||||
greeting_done: false,
|
||||
read_buf: Vec::new(),
|
||||
dest_addr: None,
|
||||
udp: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -540,8 +604,17 @@ impl WireGuardSocks5Server {
|
||||
}
|
||||
|
||||
if conn.greeting_done && conn.dest_addr.is_none() && conn.read_buf.len() >= 10 {
|
||||
// SOCKS5 connect request
|
||||
if conn.read_buf[0] != 0x05 || conn.read_buf[1] != 0x01 {
|
||||
// SOCKS5 request: CONNECT (0x01) or UDP ASSOCIATE (0x03)
|
||||
if conn.read_buf[0] != 0x05 {
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
let cmd = conn.read_buf[1];
|
||||
if cmd != 0x01 && cmd != 0x03 {
|
||||
// command not supported
|
||||
let _ = conn
|
||||
.tcp_stream
|
||||
.try_write(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
@@ -613,6 +686,75 @@ impl WireGuardSocks5Server {
|
||||
};
|
||||
|
||||
conn.read_buf.drain(..addr_len);
|
||||
|
||||
if cmd == 0x03 {
|
||||
// === SOCKS5 UDP ASSOCIATE ===
|
||||
// The request's DST is the client's intended source (typically
|
||||
// 0.0.0.0:0) and is ignored — the browser's relay source is
|
||||
// learned from its first datagram. Bind a loopback relay socket
|
||||
// the browser sends to, plus a smoltcp UDP socket that egresses
|
||||
// through the WireGuard tunnel on the interface IP.
|
||||
let relay = match UdpSocket::bind("127.0.0.1:0") {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
let _ = conn
|
||||
.tcp_stream
|
||||
.try_write(&[0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let _ = relay.set_nonblocking(true);
|
||||
let relay_port = relay.local_addr().map(|a| a.port()).unwrap_or(0);
|
||||
|
||||
// Reply with the relay endpoint (127.0.0.1:relay_port).
|
||||
if conn
|
||||
.tcp_stream
|
||||
.try_write(&[
|
||||
0x05,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
127,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
(relay_port >> 8) as u8,
|
||||
(relay_port & 0xff) as u8,
|
||||
])
|
||||
.is_err()
|
||||
{
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
|
||||
let udp_rx = udp::PacketBuffer::new(
|
||||
vec![udp::PacketMetadata::EMPTY; 32],
|
||||
vec![0u8; SMOLTCP_UDP_BUF],
|
||||
);
|
||||
let udp_tx = udp::PacketBuffer::new(
|
||||
vec![udp::PacketMetadata::EMPTY; 32],
|
||||
vec![0u8; SMOLTCP_UDP_BUF],
|
||||
);
|
||||
let mut udp_socket = udp::Socket::new(udp_rx, udp_tx);
|
||||
let local_port = 20000 + (rand::random::<u16>() % 40000);
|
||||
if udp_socket.bind(local_port).is_err() {
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Swap this connection's unused TCP socket for the UDP socket;
|
||||
// `smol_handle` now keys the UDP socket, so teardown is unchanged.
|
||||
sockets.remove(conn.smol_handle);
|
||||
conn.smol_handle = sockets.add(udp_socket);
|
||||
conn.udp = Some(UdpAssoc {
|
||||
relay,
|
||||
client_addr: None,
|
||||
});
|
||||
conn.socks_done = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
conn.dest_addr = Some(addr);
|
||||
|
||||
// Open smoltcp TCP socket to the destination
|
||||
@@ -641,6 +783,62 @@ impl WireGuardSocks5Server {
|
||||
|
||||
conn.connecting = true;
|
||||
}
|
||||
} else if conn.udp.is_some() {
|
||||
// === UDP ASSOCIATE relay ===
|
||||
// The association lives only while the TCP control connection is
|
||||
// open (RFC 1928 §6); tear down when the browser closes it.
|
||||
let mut probe = [0u8; 1];
|
||||
match conn.tcp_stream.try_read(&mut probe) {
|
||||
Ok(0) => {
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
Ok(_) => {} // ignore any data on the control channel
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(_) => {
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let handle = conn.smol_handle;
|
||||
let Some(udp) = conn.udp.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Browser → tunnel: strip the §7 header and forward the payload.
|
||||
let mut dbuf = [0u8; SMOLTCP_UDP_BUF];
|
||||
loop {
|
||||
match udp.relay.recv_from(&mut dbuf) {
|
||||
Ok((n, src)) => {
|
||||
udp.client_addr = Some(src);
|
||||
if let Some((dst, off)) = parse_udp_datagram(&dbuf[..n]) {
|
||||
let socket = sockets.get_mut::<udp::Socket>(handle);
|
||||
if socket.can_send() {
|
||||
let _ = socket.send_slice(&dbuf[off..n], dst);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// Tunnel → browser: wrap each datagram in a §7 header and relay back.
|
||||
loop {
|
||||
let socket = sockets.get_mut::<udp::Socket>(handle);
|
||||
if !socket.can_recv() {
|
||||
break;
|
||||
}
|
||||
let (payload, src) = match socket.recv() {
|
||||
Ok((data, meta)) => (data.to_vec(), meta.endpoint),
|
||||
Err(_) => break,
|
||||
};
|
||||
if let Some(client) = udp.client_addr {
|
||||
let resp = build_udp_datagram(src, &payload);
|
||||
let _ = udp.relay.send_to(&resp, client);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Data relay between SOCKS5 client and smoltcp socket
|
||||
let socket = sockets.get_mut::<TcpSocket>(conn.smol_handle);
|
||||
|
||||
@@ -39,6 +39,12 @@ pub struct WayfernConfig {
|
||||
pub block_webgl: Option<bool>,
|
||||
#[serde(default, skip_serializing)]
|
||||
pub proxy: Option<String>,
|
||||
/// Stable signature of the proxy/VPN/geoip the fingerprint's location data
|
||||
/// (timezone, latitude/longitude, language) was last computed for. Compared
|
||||
/// on launch to detect that the routing changed since creation, so the
|
||||
/// location can be refreshed instead of showing stale data.
|
||||
#[serde(default)]
|
||||
pub geo_proxy_signature: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -138,6 +144,46 @@ impl WayfernManager {
|
||||
fingerprint
|
||||
}
|
||||
|
||||
/// Derive the on-screen window size Chromium should open at, from the stored
|
||||
/// fingerprint. `Wayfern.setFingerprint` only spoofs what the page *reports*
|
||||
/// for `windowOuterWidth`/`screenWidth`/etc.; it does not move or resize the
|
||||
/// real top-level window. Without `--window-size` the OS window keeps
|
||||
/// Chromium's default, so the visible window contradicts the reported
|
||||
/// dimensions — a detectable mismatch. We pass `--window-size` so the actual
|
||||
/// window matches the fingerprint.
|
||||
///
|
||||
/// Keys are the camelCase fields Wayfern uses in its fingerprint
|
||||
/// (`windowOuterWidth`, `screenAvailWidth`, …) — NOT the dotted
|
||||
/// Camoufox-style keys. Preference order, matching how the fingerprint
|
||||
/// describes the window:
|
||||
/// 1. `windowOuterWidth` / `windowOuterHeight` — the real window size.
|
||||
/// 2. `screenAvailWidth` / `screenAvailHeight` — usable screen area.
|
||||
/// 3. `screenWidth` / `screenHeight` — full screen.
|
||||
///
|
||||
/// Returns `None` when the fingerprint carries no usable dimensions, leaving
|
||||
/// Chromium's default untouched. The fingerprint JSON may be the bare object
|
||||
/// or the legacy `{ "fingerprint": {...} }` wrapper.
|
||||
fn window_size_from_fingerprint(fingerprint_json: &str) -> Option<(u32, u32)> {
|
||||
let parsed: serde_json::Value = serde_json::from_str(fingerprint_json).ok()?;
|
||||
let fp = parsed.get("fingerprint").unwrap_or(&parsed);
|
||||
let obj = fp.as_object()?;
|
||||
|
||||
// Accept both numeric and stringified numbers (Wayfern emits numbers, but a
|
||||
// CDP echo or older saved fingerprint may stringify them).
|
||||
let read = |key: &str| -> Option<u32> {
|
||||
let v = obj.get(key)?;
|
||||
v.as_u64()
|
||||
.or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
|
||||
.filter(|n| *n > 0)
|
||||
.map(|n| n as u32)
|
||||
};
|
||||
let pair = |w: &str, h: &str| -> Option<(u32, u32)> { Some((read(w)?, read(h)?)) };
|
||||
|
||||
pair("windowOuterWidth", "windowOuterHeight")
|
||||
.or_else(|| pair("screenAvailWidth", "screenAvailHeight"))
|
||||
.or_else(|| pair("screenWidth", "screenHeight"))
|
||||
}
|
||||
|
||||
async fn wait_for_cdp_ready(
|
||||
&self,
|
||||
port: u16,
|
||||
@@ -223,6 +269,130 @@ impl WayfernManager {
|
||||
Err("No response received from CDP".into())
|
||||
}
|
||||
|
||||
/// Stable signature describing what determines this profile's geolocation
|
||||
/// (timezone, latitude/longitude, language): the geoip mode first, then the
|
||||
/// VPN, the proxy, or a direct connection. Compared across creation and
|
||||
/// launch to detect a change. The VPN case keys off `vpn_id` rather than the
|
||||
/// per-launch local port, and the proxy case off type/host/port/username so
|
||||
/// that editing the proxy is also caught.
|
||||
pub fn geo_signature(
|
||||
proxy: Option<&crate::browser::ProxySettings>,
|
||||
vpn_id: Option<&str>,
|
||||
geoip: Option<&serde_json::Value>,
|
||||
) -> String {
|
||||
match geoip {
|
||||
Some(serde_json::Value::Bool(false)) => "off".to_string(),
|
||||
Some(serde_json::Value::String(ip)) if !ip.is_empty() => format!("ip:{ip}"),
|
||||
_ => {
|
||||
if let Some(id) = vpn_id {
|
||||
format!("vpn:{id}")
|
||||
} else if let Some(p) = proxy {
|
||||
format!(
|
||||
"proxy:{}://{}@{}:{}",
|
||||
p.proxy_type.to_lowercase(),
|
||||
p.username.as_deref().unwrap_or(""),
|
||||
p.host,
|
||||
p.port
|
||||
)
|
||||
} else {
|
||||
"direct".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply timezone/geolocation fields to a fingerprint object from the proxy's
|
||||
/// exit IP (or a fixed geoip IP). Mutates `fingerprint` in place. Returns true
|
||||
/// if fresh geolocation was fetched and applied, false if geolocation is
|
||||
/// disabled or could not be resolved (in which case only safe defaults are
|
||||
/// filled in). Shared by fingerprint generation and the launch-time refresh
|
||||
/// so both produce identical location data.
|
||||
async fn apply_geolocation(
|
||||
fingerprint: &mut serde_json::Value,
|
||||
proxy: Option<&str>,
|
||||
geoip: Option<&serde_json::Value>,
|
||||
) -> bool {
|
||||
// Default to auto-detect; only an explicit `false` disables geolocation.
|
||||
let should_geolocate = !matches!(geoip, Some(serde_json::Value::Bool(false)));
|
||||
if !should_geolocate {
|
||||
return false;
|
||||
}
|
||||
|
||||
let geo_result = async {
|
||||
let ip = match geoip {
|
||||
Some(serde_json::Value::String(ip_str)) => ip_str.clone(),
|
||||
_ => crate::ip_utils::fetch_public_ip(proxy)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch public IP: {e}"))?,
|
||||
};
|
||||
crate::camoufox::geolocation::get_geolocation(&ip)
|
||||
.map_err(|e| format!("Failed to get geolocation for IP {ip}: {e}"))
|
||||
}
|
||||
.await;
|
||||
|
||||
match geo_result {
|
||||
Ok(geo) => {
|
||||
if let Some(obj) = fingerprint.as_object_mut() {
|
||||
obj.insert("timezone".to_string(), json!(geo.timezone));
|
||||
// Calculate timezone offset from IANA timezone name
|
||||
if let Ok(tz) = geo.timezone.parse::<chrono_tz::Tz>() {
|
||||
use chrono::Offset;
|
||||
let now = chrono::Utc::now().with_timezone(&tz);
|
||||
let offset_seconds = now.offset().fix().local_minus_utc();
|
||||
let offset_minutes = -(offset_seconds / 60);
|
||||
obj.insert("timezoneOffset".to_string(), json!(offset_minutes));
|
||||
}
|
||||
obj.insert("latitude".to_string(), json!(geo.latitude));
|
||||
obj.insert("longitude".to_string(), json!(geo.longitude));
|
||||
let locale_str = geo.locale.as_string();
|
||||
obj.insert("language".to_string(), json!(&locale_str));
|
||||
obj.insert(
|
||||
"languages".to_string(),
|
||||
json!([&locale_str, &geo.locale.language]),
|
||||
);
|
||||
}
|
||||
log::info!(
|
||||
"Applied geolocation to Wayfern fingerprint: {} ({})",
|
||||
geo.locale.as_string(),
|
||||
geo.timezone
|
||||
);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Geolocation failed, using defaults: {e}");
|
||||
if let Some(obj) = fingerprint.as_object_mut() {
|
||||
if !obj.contains_key("timezone") {
|
||||
obj.insert("timezone".to_string(), json!("America/New_York"));
|
||||
}
|
||||
if !obj.contains_key("timezoneOffset") {
|
||||
obj.insert("timezoneOffset".to_string(), json!(300));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh ONLY the location fields (timezone, offset, latitude/longitude,
|
||||
/// language) of an already-generated fingerprint to match the current proxy,
|
||||
/// leaving every other fingerprint field untouched. `proxy` is the local
|
||||
/// proxy URL the browser will use. Returns the updated fingerprint JSON on
|
||||
/// success, or None if geolocation is disabled or could not be resolved, in
|
||||
/// which case the caller keeps the existing fingerprint and retries on the
|
||||
/// next launch.
|
||||
pub async fn refresh_fingerprint_geolocation(
|
||||
fingerprint_json: &str,
|
||||
proxy: Option<&str>,
|
||||
geoip: Option<&serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
let mut fp: serde_json::Value = serde_json::from_str(fingerprint_json).ok()?;
|
||||
if Self::apply_geolocation(&mut fp, proxy, geoip).await {
|
||||
serde_json::to_string(&fp).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate_fingerprint_config(
|
||||
&self,
|
||||
_app_handle: &AppHandle,
|
||||
@@ -384,70 +554,14 @@ impl WayfernManager {
|
||||
// Normalize the fingerprint: convert JSON string fields to proper types
|
||||
let mut normalized = Self::normalize_fingerprint(fp);
|
||||
|
||||
// Apply geolocation based on proxy IP or geoip config
|
||||
let geoip_option = config.geoip.as_ref();
|
||||
let should_geolocate = match geoip_option {
|
||||
Some(serde_json::Value::Bool(false)) => false,
|
||||
_ => true, // Default to auto-detect
|
||||
};
|
||||
|
||||
if should_geolocate {
|
||||
let geo_result = async {
|
||||
let ip = match geoip_option {
|
||||
Some(serde_json::Value::String(ip_str)) => ip_str.clone(),
|
||||
_ => {
|
||||
// Auto-detect IP, optionally through proxy
|
||||
crate::ip_utils::fetch_public_ip(config.proxy.as_deref())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch public IP: {e}"))?
|
||||
}
|
||||
};
|
||||
|
||||
crate::camoufox::geolocation::get_geolocation(&ip)
|
||||
.map_err(|e| format!("Failed to get geolocation for IP {ip}: {e}"))
|
||||
}
|
||||
.await;
|
||||
|
||||
match geo_result {
|
||||
Ok(geo) => {
|
||||
if let Some(obj) = normalized.as_object_mut() {
|
||||
obj.insert("timezone".to_string(), json!(geo.timezone));
|
||||
// Calculate timezone offset from IANA timezone name
|
||||
if let Ok(tz) = geo.timezone.parse::<chrono_tz::Tz>() {
|
||||
use chrono::Offset;
|
||||
let now = chrono::Utc::now().with_timezone(&tz);
|
||||
let offset_seconds = now.offset().fix().local_minus_utc();
|
||||
let offset_minutes = -(offset_seconds / 60);
|
||||
obj.insert("timezoneOffset".to_string(), json!(offset_minutes));
|
||||
}
|
||||
obj.insert("latitude".to_string(), json!(geo.latitude));
|
||||
obj.insert("longitude".to_string(), json!(geo.longitude));
|
||||
let locale_str = geo.locale.as_string();
|
||||
obj.insert("language".to_string(), json!(&locale_str));
|
||||
obj.insert(
|
||||
"languages".to_string(),
|
||||
json!([&locale_str, &geo.locale.language]),
|
||||
);
|
||||
}
|
||||
log::info!(
|
||||
"Applied geolocation to Wayfern fingerprint: {} ({})",
|
||||
geo.locale.as_string(),
|
||||
geo.timezone
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Geolocation failed, using defaults: {e}");
|
||||
if let Some(obj) = normalized.as_object_mut() {
|
||||
if !obj.contains_key("timezone") {
|
||||
obj.insert("timezone".to_string(), json!("America/New_York"));
|
||||
}
|
||||
if !obj.contains_key("timezoneOffset") {
|
||||
obj.insert("timezoneOffset".to_string(), json!(300));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Apply timezone/geolocation for the proxy this fingerprint is being
|
||||
// generated against. Shared with the launch-time location refresh.
|
||||
Self::apply_geolocation(
|
||||
&mut normalized,
|
||||
config.proxy.as_deref(),
|
||||
config.geoip.as_ref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
normalized
|
||||
}
|
||||
@@ -611,13 +725,30 @@ impl WayfernManager {
|
||||
"--disable-session-crashed-bubble".to_string(),
|
||||
"--hide-crash-restore-bubble".to_string(),
|
||||
"--disable-infobars".to_string(),
|
||||
"--disable-features=DialMediaRouteProvider,DnsOverHttps,AsyncDns".to_string(),
|
||||
// Prefetch* / NoStatePrefetch: cross-site Speculation-Rules prefetch uses
|
||||
// an isolated NetworkContext that defaults to DIRECT egress (real host IP
|
||||
// leaks past the per-profile proxy). Disabling via a LAUNCH FLAG cannot be
|
||||
// re-enabled by an imported/synced network_prediction_options pref (which a
|
||||
// compile-time pref default could be).
|
||||
"--disable-features=DialMediaRouteProvider,DnsOverHttps,AsyncDns,Prefetch,PrefetchProxy,SpeculationRulesPrefetchFuture,NoStatePrefetch".to_string(),
|
||||
"--use-mock-keychain".to_string(),
|
||||
"--password-store=basic".to_string(),
|
||||
];
|
||||
|
||||
if headless {
|
||||
args.push("--headless=new".to_string());
|
||||
} else if let Some((w, h)) = config
|
||||
.fingerprint
|
||||
.as_deref()
|
||||
.and_then(Self::window_size_from_fingerprint)
|
||||
{
|
||||
// Size the real OS window to match the fingerprint so the visible window
|
||||
// agrees with the reported windowOuterWidth/screen dimensions. Anchor at
|
||||
// 0,0 so the window also fits within the spoofed screen origin. Skipped in
|
||||
// headless mode, where there is no on-screen window.
|
||||
log::info!("Sizing Wayfern window to fingerprint dimensions: {w}x{h}");
|
||||
args.push(format!("--window-size={w},{h}"));
|
||||
args.push("--window-position=0,0".to_string());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -671,9 +802,21 @@ impl WayfernManager {
|
||||
}
|
||||
|
||||
if let Some(proxy) = proxy_url {
|
||||
// Map the local proxy scheme to the matching PAC directive. SOCKS5 lets
|
||||
// Chromium route UDP (QUIC/WebRTC) and resolve DNS through the proxy;
|
||||
// PROXY is HTTP CONNECT (TCP only). The host:port is the same either way.
|
||||
let (pac_directive, host_port) = if let Some(rest) = proxy.strip_prefix("socks5://") {
|
||||
("SOCKS5", rest)
|
||||
} else {
|
||||
(
|
||||
"PROXY",
|
||||
proxy
|
||||
.trim_start_matches("http://")
|
||||
.trim_start_matches("https://"),
|
||||
)
|
||||
};
|
||||
let pac_data = format!(
|
||||
"data:application/x-ns-proxy-autoconfig,function FindProxyForURL(url,host){{return \"PROXY {}\";}}",
|
||||
proxy.trim_start_matches("http://").trim_start_matches("https://")
|
||||
"data:application/x-ns-proxy-autoconfig,function FindProxyForURL(url,host){{return \"{pac_directive} {host_port}\";}}",
|
||||
);
|
||||
args.push(format!("--proxy-pac-url={pac_data}"));
|
||||
args.push("--dns-prefetch-disable".to_string());
|
||||
@@ -1198,3 +1341,72 @@ impl WayfernManager {
|
||||
lazy_static::lazy_static! {
|
||||
static ref WAYFERN_MANAGER: WayfernManager = WayfernManager::new();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn window_size_prefers_outer_window_dimensions() {
|
||||
// Field names + values mirror a real Wayfern fingerprint (camelCase).
|
||||
let fp = r#"{"windowOuterWidth": 1268, "windowOuterHeight": 764,
|
||||
"windowInnerWidth": 1253, "windowInnerHeight": 630,
|
||||
"screenAvailWidth": 1280, "screenAvailHeight": 775,
|
||||
"screenWidth": 1280, "screenHeight": 800}"#;
|
||||
assert_eq!(
|
||||
WayfernManager::window_size_from_fingerprint(fp),
|
||||
Some((1268, 764))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_size_falls_back_to_avail_then_full_screen() {
|
||||
let avail = r#"{"screenAvailWidth": 1280, "screenAvailHeight": 775,
|
||||
"screenWidth": 1280, "screenHeight": 800}"#;
|
||||
assert_eq!(
|
||||
WayfernManager::window_size_from_fingerprint(avail),
|
||||
Some((1280, 775))
|
||||
);
|
||||
|
||||
let full = r#"{"screenWidth": 2560, "screenHeight": 1440}"#;
|
||||
assert_eq!(
|
||||
WayfernManager::window_size_from_fingerprint(full),
|
||||
Some((2560, 1440))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_size_handles_wrapper_and_stringified_numbers() {
|
||||
let wrapped = r#"{"fingerprint": {"windowOuterWidth": "1366", "windowOuterHeight": "768"}}"#;
|
||||
assert_eq!(
|
||||
WayfernManager::window_size_from_fingerprint(wrapped),
|
||||
Some((1366, 768))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_size_none_when_missing_or_invalid() {
|
||||
// No dimensions at all.
|
||||
assert_eq!(
|
||||
WayfernManager::window_size_from_fingerprint(r#"{"userAgent": "x"}"#),
|
||||
None
|
||||
);
|
||||
// A width with no matching height is not a usable pair.
|
||||
assert_eq!(
|
||||
WayfernManager::window_size_from_fingerprint(r#"{"windowOuterWidth": 1268}"#),
|
||||
None
|
||||
);
|
||||
// Zero is rejected as a degenerate size.
|
||||
assert_eq!(
|
||||
WayfernManager::window_size_from_fingerprint(
|
||||
r#"{"windowOuterWidth": 0, "windowOuterHeight": 0}"#
|
||||
),
|
||||
None
|
||||
);
|
||||
// Not valid JSON.
|
||||
assert_eq!(
|
||||
WayfernManager::window_size_from_fingerprint("not json"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.25.0",
|
||||
"version": "0.27.1",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
+222
-48
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AccountPage } from "@/components/account-page";
|
||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { CamoufoxDeprecationDialog } from "@/components/camoufox-deprecation-dialog";
|
||||
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
||||
import { CloseConfirmDialog } from "@/components/close-confirm-dialog";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
@@ -59,6 +60,7 @@ import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { getEntitlements } from "@/lib/entitlements";
|
||||
import {
|
||||
ONBOARDING_TOUR_FINISHED_EVENT,
|
||||
setOnboardingActive,
|
||||
@@ -225,10 +227,11 @@ export default function Home() {
|
||||
|
||||
// Cloud auth for cross-OS unlock
|
||||
const { user: cloudUser } = useCloudAuth();
|
||||
const crossOsUnlocked =
|
||||
cloudUser?.plan !== "free" &&
|
||||
(cloudUser?.subscriptionStatus === "active" ||
|
||||
cloudUser?.planPeriod === "lifetime");
|
||||
const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints;
|
||||
// Bulk run/stop is a paid (browser automation) feature, matching the
|
||||
// /v1/profiles/batch/run API gate. Free/starter users see the bulk Run/Stop
|
||||
// actions disabled with a Pro badge.
|
||||
const automationUnlocked = getEntitlements(cloudUser).browserAutomation;
|
||||
|
||||
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
|
||||
useState(false);
|
||||
@@ -709,49 +712,67 @@ export default function Home() {
|
||||
);
|
||||
|
||||
const listenForUrlEvents = useCallback(async () => {
|
||||
// Collect every listener we register so that — whether setup completes or
|
||||
// throws partway through — we tear down exactly what was registered.
|
||||
// Previously the Tauri unlisten handles were discarded (so re-runs stacked
|
||||
// duplicate handlers and a single URL was handled N times), and a failing
|
||||
// listen() call would leak the listeners that had already succeeded.
|
||||
const unlisteners: Array<() => void> = [];
|
||||
let handleLogoUrlEvent: ((event: CustomEvent) => void) | undefined;
|
||||
const teardown = () => {
|
||||
for (const unlisten of unlisteners) unlisten();
|
||||
if (handleLogoUrlEvent) {
|
||||
window.removeEventListener(
|
||||
"url-open-request",
|
||||
handleLogoUrlEvent as EventListener,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Listen for URL open events from the deep link handler (when app is already running)
|
||||
await listen<string>("url-open-request", (event) => {
|
||||
console.log("Received URL open request:", event.payload);
|
||||
handleUrlOpen(event.payload);
|
||||
});
|
||||
unlisteners.push(
|
||||
await listen<string>("url-open-request", (event) => {
|
||||
console.log("Received URL open request:", event.payload);
|
||||
handleUrlOpen(event.payload);
|
||||
}),
|
||||
);
|
||||
|
||||
// Listen for show profile selector events
|
||||
await listen<string>("show-profile-selector", (event) => {
|
||||
console.log("Received show profile selector request:", event.payload);
|
||||
handleUrlOpen(event.payload);
|
||||
});
|
||||
unlisteners.push(
|
||||
await listen<string>("show-profile-selector", (event) => {
|
||||
console.log("Received show profile selector request:", event.payload);
|
||||
handleUrlOpen(event.payload);
|
||||
}),
|
||||
);
|
||||
|
||||
// Listen for show create profile dialog events
|
||||
await listen<string>("show-create-profile-dialog", (event) => {
|
||||
console.log(
|
||||
"Received show create profile dialog request:",
|
||||
event.payload,
|
||||
);
|
||||
showErrorToast(t("errors.noProfilesForUrl"));
|
||||
setCreateProfileDialogOpen(true);
|
||||
});
|
||||
unlisteners.push(
|
||||
await listen<string>("show-create-profile-dialog", (event) => {
|
||||
console.log(
|
||||
"Received show create profile dialog request:",
|
||||
event.payload,
|
||||
);
|
||||
showErrorToast(t("errors.noProfilesForUrl"));
|
||||
setCreateProfileDialogOpen(true);
|
||||
}),
|
||||
);
|
||||
|
||||
// Listen for custom logo click events
|
||||
const handleLogoUrlEvent = (event: CustomEvent) => {
|
||||
handleLogoUrlEvent = (event: CustomEvent) => {
|
||||
console.log("Received logo URL event:", event.detail);
|
||||
handleUrlOpen(event.detail);
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
"url-open-request",
|
||||
handleLogoUrlEvent as EventListener,
|
||||
);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"url-open-request",
|
||||
handleLogoUrlEvent as EventListener,
|
||||
);
|
||||
};
|
||||
return teardown;
|
||||
} catch (error) {
|
||||
console.error("Failed to setup URL listener:", error);
|
||||
// Tear down whatever did register before the failure so nothing leaks.
|
||||
teardown();
|
||||
}
|
||||
}, [handleUrlOpen, t]);
|
||||
|
||||
@@ -1129,6 +1150,75 @@ export default function Home() {
|
||||
setCookieCopyDialogOpen(true);
|
||||
}, [selectedProfiles, profiles, t]);
|
||||
|
||||
const [pendingBulkAction, setPendingBulkAction] = useState<{
|
||||
action: "run" | "stop";
|
||||
profiles: BrowserProfile[];
|
||||
} | null>(null);
|
||||
const [isBulkActing, setIsBulkActing] = useState(false);
|
||||
|
||||
const executeBulkRun = useCallback(
|
||||
async (targets: BrowserProfile[]) => {
|
||||
setIsBulkActing(true);
|
||||
try {
|
||||
await Promise.allSettled(targets.map((p) => launchProfile(p)));
|
||||
setSelectedProfiles([]);
|
||||
} finally {
|
||||
setIsBulkActing(false);
|
||||
setPendingBulkAction(null);
|
||||
}
|
||||
},
|
||||
[launchProfile],
|
||||
);
|
||||
|
||||
const executeBulkStop = useCallback(
|
||||
async (targets: BrowserProfile[]) => {
|
||||
setIsBulkActing(true);
|
||||
try {
|
||||
await Promise.allSettled(targets.map((p) => handleKillProfile(p)));
|
||||
setSelectedProfiles([]);
|
||||
} finally {
|
||||
setIsBulkActing(false);
|
||||
setPendingBulkAction(null);
|
||||
}
|
||||
},
|
||||
[handleKillProfile],
|
||||
);
|
||||
|
||||
// Bulk run/stop only touch eligible profiles (run: not already running;
|
||||
// stop: currently running). An empty result shows a toast instead of a silent
|
||||
// no-op (guard), and 10+ targets require confirmation before launching/stopping.
|
||||
const handleBulkRun = useCallback(() => {
|
||||
if (selectedProfiles.length === 0) return;
|
||||
const targets = profiles.filter(
|
||||
(p) => selectedProfiles.includes(p.id) && !runningProfiles.has(p.id),
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
showErrorToast(t("profiles.bulkRun.noneToRun"));
|
||||
return;
|
||||
}
|
||||
if (targets.length >= 10) {
|
||||
setPendingBulkAction({ action: "run", profiles: targets });
|
||||
return;
|
||||
}
|
||||
void executeBulkRun(targets);
|
||||
}, [selectedProfiles, profiles, runningProfiles, executeBulkRun, t]);
|
||||
|
||||
const handleBulkStop = useCallback(() => {
|
||||
if (selectedProfiles.length === 0) return;
|
||||
const targets = profiles.filter(
|
||||
(p) => selectedProfiles.includes(p.id) && runningProfiles.has(p.id),
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
showErrorToast(t("profiles.bulkStop.noneToStop"));
|
||||
return;
|
||||
}
|
||||
if (targets.length >= 10) {
|
||||
setPendingBulkAction({ action: "stop", profiles: targets });
|
||||
return;
|
||||
}
|
||||
void executeBulkStop(targets);
|
||||
}, [selectedProfiles, profiles, runningProfiles, executeBulkStop, t]);
|
||||
|
||||
const handleCopyCookiesToProfile = useCallback((profile: BrowserProfile) => {
|
||||
setSelectedProfilesForCookies([profile.id]);
|
||||
setCookieCopyDialogOpen(true);
|
||||
@@ -1168,11 +1258,14 @@ export default function Home() {
|
||||
profileId: profile.id,
|
||||
syncMode: enabling ? "Regular" : "Disabled",
|
||||
});
|
||||
showSuccessToast(enabling ? "Sync enabled" : "Sync disabled", {
|
||||
description: enabling
|
||||
? "Profile sync has been enabled"
|
||||
: "Profile sync has been disabled",
|
||||
});
|
||||
showSuccessToast(
|
||||
t(enabling ? "sync.enabledToast" : "sync.disabledToast"),
|
||||
{
|
||||
description: t(
|
||||
enabling ? "sync.enabledDescription" : "sync.disabledDescription",
|
||||
),
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast(t("errors.updateSyncSettingsFailed"));
|
||||
@@ -1182,6 +1275,7 @@ export default function Home() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let unlistenStatus: (() => void) | undefined;
|
||||
let unlistenProgress: (() => void) | undefined;
|
||||
const profilesWithTransfer = new Set<string>();
|
||||
@@ -1258,25 +1352,35 @@ export default function Home() {
|
||||
);
|
||||
}
|
||||
});
|
||||
// If the effect was torn down while we were awaiting the listeners,
|
||||
// unlisten immediately — the cleanup below already ran and would have
|
||||
// missed these handles. (Tauri unlisten is safe to call more than once.)
|
||||
if (disposed) {
|
||||
unlistenStatus?.();
|
||||
unlistenProgress?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to listen for sync events:", error);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (unlistenStatus) unlistenStatus();
|
||||
if (unlistenProgress) unlistenProgress();
|
||||
};
|
||||
}, [profiles, t]);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for URL open events and get cleanup function
|
||||
const setupListeners = async () => {
|
||||
const cleanup = await listenForUrlEvents();
|
||||
return cleanup;
|
||||
};
|
||||
|
||||
// Listen for URL open events. Guard against the effect tearing down (or
|
||||
// re-running) before the async listener setup resolves: if that happens,
|
||||
// run the cleanup as soon as it's available so the listeners never leak.
|
||||
let cleanup: (() => void) | undefined;
|
||||
void setupListeners().then((cleanupFn) => {
|
||||
let disposed = false;
|
||||
void listenForUrlEvents().then((cleanupFn) => {
|
||||
if (disposed) {
|
||||
cleanupFn?.();
|
||||
return;
|
||||
}
|
||||
cleanup = cleanupFn;
|
||||
});
|
||||
|
||||
@@ -1304,10 +1408,9 @@ export default function Home() {
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearInterval(updateInterval);
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
}
|
||||
cleanup?.();
|
||||
};
|
||||
}, [
|
||||
checkForUpdates,
|
||||
@@ -1321,10 +1424,12 @@ export default function Home() {
|
||||
// E2E encryption listeners — surface password-required prompts and rollover
|
||||
// progress so the user isn't left guessing whether sealing finished.
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let unlistenRequired: (() => void) | undefined;
|
||||
let unlistenStarted: (() => void) | undefined;
|
||||
let unlistenProgress: (() => void) | undefined;
|
||||
let unlistenCompleted: (() => void) | undefined;
|
||||
let unlistenWayfernBlocked: (() => void) | undefined;
|
||||
|
||||
void (async () => {
|
||||
unlistenRequired = await listen(
|
||||
@@ -1386,13 +1491,35 @@ export default function Home() {
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
unlistenWayfernBlocked = await listen("wayfern-paid-blocked", () => {
|
||||
showToast({
|
||||
id: "wayfern-paid-blocked",
|
||||
type: "error",
|
||||
title: t("wayfernBlocked.title"),
|
||||
description: t("wayfernBlocked.description"),
|
||||
duration: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
// If the effect was torn down mid-setup, the cleanup below already ran
|
||||
// before these handles existed — unlisten them now so nothing leaks.
|
||||
if (disposed) {
|
||||
unlistenRequired?.();
|
||||
unlistenStarted?.();
|
||||
unlistenProgress?.();
|
||||
unlistenCompleted?.();
|
||||
unlistenWayfernBlocked?.();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlistenRequired?.();
|
||||
unlistenStarted?.();
|
||||
unlistenProgress?.();
|
||||
unlistenCompleted?.();
|
||||
unlistenWayfernBlocked?.();
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
@@ -1510,8 +1637,9 @@ export default function Home() {
|
||||
: t(`pageTitle.${currentPage}`);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background font-(family-name:--font-geist-sans)">
|
||||
<div className="flex h-dvh flex-col bg-background font-(family-name:--font-geist-sans)">
|
||||
<CloseConfirmDialog />
|
||||
<CamoufoxDeprecationDialog profiles={profiles} />
|
||||
<HomeHeader
|
||||
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
||||
searchQuery={searchQuery}
|
||||
@@ -1522,11 +1650,11 @@ export default function Home() {
|
||||
onGroupSelect={handleSelectGroup}
|
||||
pageTitle={subPageTitle}
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<RailNav currentPage={currentPage} onNavigate={handleRailNavigate} />
|
||||
<main className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{currentPage === "profiles" && (
|
||||
<div className="px-3 pt-2.5 flex flex-col flex-1 min-h-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2.5">
|
||||
{isLoading && groupsData.length === 0 ? null : null}
|
||||
<ProfilesDataTable
|
||||
profiles={filteredProfiles}
|
||||
@@ -1554,6 +1682,9 @@ export default function Home() {
|
||||
onBulkGroupAssignment={handleBulkGroupAssignment}
|
||||
onBulkProxyAssignment={handleBulkProxyAssignment}
|
||||
onBulkCopyCookies={handleBulkCopyCookies}
|
||||
onBulkRun={handleBulkRun}
|
||||
onBulkStop={handleBulkStop}
|
||||
bulkActionsUnlocked={automationUnlocked}
|
||||
onBulkExtensionGroupAssignment={
|
||||
handleBulkExtensionGroupAssignment
|
||||
}
|
||||
@@ -1853,6 +1984,49 @@ export default function Home() {
|
||||
profile={currentProfileForCookieManagement}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={pendingBulkAction !== null}
|
||||
onClose={() => {
|
||||
setPendingBulkAction(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (!pendingBulkAction) return;
|
||||
if (pendingBulkAction.action === "run") {
|
||||
void executeBulkRun(pendingBulkAction.profiles);
|
||||
} else {
|
||||
void executeBulkStop(pendingBulkAction.profiles);
|
||||
}
|
||||
}}
|
||||
title={
|
||||
pendingBulkAction?.action === "stop"
|
||||
? t("profiles.bulkStop.confirmTitle", {
|
||||
count: pendingBulkAction?.profiles.length ?? 0,
|
||||
})
|
||||
: t("profiles.bulkRun.confirmTitle", {
|
||||
count: pendingBulkAction?.profiles.length ?? 0,
|
||||
})
|
||||
}
|
||||
description={
|
||||
pendingBulkAction?.action === "stop"
|
||||
? t("profiles.bulkStop.confirmDescription", {
|
||||
count: pendingBulkAction?.profiles.length ?? 0,
|
||||
})
|
||||
: t("profiles.bulkRun.confirmDescription", {
|
||||
count: pendingBulkAction?.profiles.length ?? 0,
|
||||
})
|
||||
}
|
||||
confirmButtonText={
|
||||
pendingBulkAction?.action === "stop"
|
||||
? t("profiles.bulkStop.confirmButton", {
|
||||
count: pendingBulkAction?.profiles.length ?? 0,
|
||||
})
|
||||
: t("profiles.bulkRun.confirmButton", {
|
||||
count: pendingBulkAction?.profiles.length ?? 0,
|
||||
})
|
||||
}
|
||||
confirmButtonVariant="default"
|
||||
isLoading={isBulkActing}
|
||||
/>
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={showBulkDeleteConfirmation}
|
||||
onClose={() => {
|
||||
|
||||
@@ -25,7 +25,9 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { getEntitlements } from "@/lib/entitlements";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SyncSettings } from "@/types";
|
||||
|
||||
interface AccountPageProps {
|
||||
@@ -196,8 +198,13 @@ export function AccountPage({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-2xl flex flex-col">
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<DialogContent className="flex max-h-[calc(100vh-4rem)] max-w-2xl flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4",
|
||||
subPage && "mx-auto w-full max-w-2xl",
|
||||
)}
|
||||
>
|
||||
<AnimatedTabs defaultValue="account">
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="account">
|
||||
@@ -219,16 +226,16 @@ export function AccountPage({
|
||||
<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 size-12 rounded-full bg-accent text-foreground shrink-0">
|
||||
<div className="grid size-12 shrink-0 place-items-center rounded-full bg-accent text-foreground">
|
||||
<LuUser className="size-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isLoggedIn && user ? (
|
||||
<>
|
||||
<h2 className="text-base font-semibold truncate">
|
||||
<h2 className="truncate text-base font-semibold">
|
||||
{user.email}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.plan", {
|
||||
plan: user.plan,
|
||||
period: user.planPeriod ?? "—",
|
||||
@@ -240,7 +247,7 @@ export function AccountPage({
|
||||
<h2 className="text-base font-semibold">
|
||||
{t("account.signedOut")}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.signedOutDescription")}
|
||||
</p>
|
||||
</>
|
||||
@@ -250,39 +257,39 @@ export function AccountPage({
|
||||
|
||||
{isLoggedIn && user && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.plan")}
|
||||
</p>
|
||||
<p className="mt-0.5 font-medium uppercase">
|
||||
{user.plan}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.status")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.subscriptionStatus ?? "—"}</p>
|
||||
</div>
|
||||
{user.teamRole && (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.teamRole")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.teamRole}</p>
|
||||
</div>
|
||||
)}
|
||||
{user.planPeriod && (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.period")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.planPeriod}</p>
|
||||
</div>
|
||||
)}
|
||||
{typeof user.deviceOrdinal === "number" && (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.device")}
|
||||
</p>
|
||||
<p className="mt-0.5">
|
||||
@@ -298,7 +305,7 @@ export function AccountPage({
|
||||
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
user.plan !== "free" &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === false && (
|
||||
<p className="text-xs text-warning">
|
||||
{t("account.automationPrimaryOnly")}
|
||||
@@ -306,7 +313,7 @@ export function AccountPage({
|
||||
)}
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
user.plan !== "free" &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === true &&
|
||||
(user.deviceCount ?? 1) > 1 && (
|
||||
<p className="text-xs text-success">
|
||||
@@ -314,7 +321,7 @@ export function AccountPage({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Button
|
||||
@@ -324,7 +331,7 @@ export function AccountPage({
|
||||
void handleRefresh();
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuRefreshCw className="size-3" />
|
||||
{t("account.refresh")}
|
||||
@@ -337,7 +344,7 @@ export function AccountPage({
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuLogOut className="size-3" />
|
||||
{t("account.logout")}
|
||||
@@ -347,7 +354,7 @@ export function AccountPage({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onOpenSignIn}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuCloud className="size-3" />
|
||||
{t("account.signIn")}
|
||||
@@ -373,7 +380,7 @@ export function AccountPage({
|
||||
<p className="text-sm font-medium">
|
||||
{t("account.selfHosted.title")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.selfHosted.description")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -424,7 +431,7 @@ export function AccountPage({
|
||||
? t("common.aria.hideToken")
|
||||
: t("common.aria.showToken")
|
||||
}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken ? (
|
||||
<LuEyeOff className="size-3.5" />
|
||||
@@ -442,7 +449,7 @@ export function AccountPage({
|
||||
{connectionStatus === "connected" && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="text-success-foreground bg-success"
|
||||
className="bg-success text-success-foreground"
|
||||
>
|
||||
{t("sync.status.connected")}
|
||||
</Badge>
|
||||
|
||||
@@ -35,13 +35,13 @@ 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="shrink-0 size-5" />
|
||||
<div className="flex w-full max-w-md items-start rounded-lg border border-border bg-card p-4 text-card-foreground shadow-lg">
|
||||
<div className="mt-0.5 mr-3">
|
||||
<LuCheckCheck className="size-5 shrink-0" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex gap-2 justify-between items-start">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{updateReady
|
||||
@@ -59,18 +59,18 @@ export function AppUpdateToast({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
className="p-0 size-6 shrink-0"
|
||||
className="size-6 shrink-0 p-0"
|
||||
>
|
||||
<FaTimes className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center mt-3">
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{updateReady ? (
|
||||
<RippleButton
|
||||
onClick={() => void handleRestartClick()}
|
||||
size="sm"
|
||||
className="flex gap-2 items-center text-xs"
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<LuCheckCheck className="size-3" />
|
||||
{t("appUpdate.toast.restartNow")}
|
||||
@@ -81,7 +81,7 @@ export function AppUpdateToast({
|
||||
<RippleButton
|
||||
onClick={handleViewRelease}
|
||||
size="sm"
|
||||
className="flex gap-2 items-center text-xs"
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<FaExternalLinkAlt className="size-3" />
|
||||
{t("appUpdate.toast.viewRelease")}
|
||||
|
||||
@@ -63,11 +63,11 @@ export function BandwidthMiniChart({
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative flex items-center gap-1.5 px-2 rounded cursor-pointer hover:bg-accent/50 transition-colors min-w-[120px] border-none bg-transparent",
|
||||
"relative flex w-full min-w-0 cursor-pointer items-center gap-1.5 rounded border-none bg-transparent px-2 transition-colors hover:bg-accent/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 h-3 pointer-events-none">
|
||||
<div className="pointer-events-none h-3 min-w-0 flex-1">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
@@ -111,7 +111,7 @@ export function BandwidthMiniChart({
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap min-w-[60px] text-right">
|
||||
<span className="min-w-[60px] shrink-0 text-right text-xs whitespace-nowrap text-muted-foreground">
|
||||
{formatBytes(currentBandwidth)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -149,7 +149,7 @@ export function CamoufoxConfigDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="flex h-[min(85vh,52rem)] max-w-3xl flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>
|
||||
{isRunning
|
||||
@@ -164,7 +164,7 @@ export function CamoufoxConfigDialog({
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 h-[300px]">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="py-4">
|
||||
{profile.browser === "wayfern" ? (
|
||||
<WayfernConfigForm
|
||||
@@ -193,7 +193,7 @@ export function CamoufoxConfigDialog({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="shrink-0 pt-4 border-t">
|
||||
<DialogFooter className="shrink-0 border-t pt-4">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{isRunning ? t("common.buttons.close") : t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface CamoufoxDeprecationDialogProps {
|
||||
profiles: BrowserProfile[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Warns users who still have Camoufox profiles that Camoufox support is ending.
|
||||
* Shown once per app session (this component mounts for the app lifetime), only
|
||||
* when at least one Camoufox profile exists. Not a toast — a blocking dialog so
|
||||
* the deprecation can't be missed.
|
||||
*/
|
||||
export function CamoufoxDeprecationDialog({
|
||||
profiles,
|
||||
}: CamoufoxDeprecationDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [shown, setShown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (shown) return;
|
||||
const hasCamoufox = profiles.some((p) => p.browser === "camoufox");
|
||||
if (hasCamoufox) {
|
||||
setIsOpen(true);
|
||||
setShown(true);
|
||||
}
|
||||
}, [profiles, shown]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuTriangleAlert className="size-5 text-warning" />
|
||||
{t("camoufoxDeprecation.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("camoufoxDeprecation.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void openUrl(
|
||||
"https://github.com/zhom/donutbrowser/discussions/426",
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.learnMore")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("camoufoxDeprecation.acknowledge")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export function CloneProfileDialog({
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("profileInfo.clone.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -74,7 +74,7 @@ function Tokens({ tokens }: { tokens: string[] }) {
|
||||
{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"
|
||||
className="inline-flex h-5 min-w-5 items-center justify-center rounded border border-border bg-muted px-1 text-[10px] font-medium text-muted-foreground"
|
||||
>
|
||||
{tok}
|
||||
</kbd>
|
||||
@@ -157,7 +157,7 @@ export function CommandPalette({
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange} filter={fuzzyFilter}>
|
||||
<CommandInput placeholder={t("commandPalette.placeholder")} />
|
||||
<CommandList>
|
||||
<CommandList className="max-h-[min(60vh,480px)]">
|
||||
<CommandEmpty>{t("commandPalette.empty")}</CommandEmpty>
|
||||
|
||||
<CommandGroup heading={t("commandPalette.groups.navigation")}>
|
||||
@@ -205,7 +205,7 @@ export function CommandPalette({
|
||||
}}
|
||||
>
|
||||
<LuCircleStop />
|
||||
<span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t("commandPalette.actions.stopProfile", {
|
||||
name: p.name,
|
||||
})}
|
||||
@@ -221,7 +221,7 @@ export function CommandPalette({
|
||||
}}
|
||||
>
|
||||
<LuPlay />
|
||||
<span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t("commandPalette.actions.launchProfile", {
|
||||
name: p.name,
|
||||
})}
|
||||
@@ -239,7 +239,7 @@ export function CommandPalette({
|
||||
}}
|
||||
>
|
||||
<LuInfo />
|
||||
<span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t("commandPalette.actions.profileInfo", { name: p.name })}
|
||||
</span>
|
||||
</CommandItem>
|
||||
|
||||
@@ -332,7 +332,7 @@ export function CookieCopyDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogContent className="flex max-h-[80vh] max-w-[min(48rem,calc(100%-4rem))] flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuCookie className="size-5" />
|
||||
@@ -349,7 +349,7 @@ export function CookieCopyDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4">
|
||||
<div className="flex-1 space-y-4 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("cookies.copy.sourceProfile")}</Label>
|
||||
<Select
|
||||
@@ -393,7 +393,7 @@ export function CookieCopyDialog({
|
||||
count: targetProfiles.length,
|
||||
})}
|
||||
</Label>
|
||||
<div className="p-2 bg-muted rounded-md max-h-20 overflow-y-auto">
|
||||
<div className="max-h-20 overflow-y-auto rounded-md bg-muted p-2">
|
||||
{targetProfiles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{sourceProfileId
|
||||
@@ -405,7 +405,7 @@ export function CookieCopyDialog({
|
||||
{targetProfiles.map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 bg-background rounded text-sm"
|
||||
className="inline-flex items-center gap-1 rounded bg-background px-2 py-0.5 text-sm"
|
||||
>
|
||||
{p.name}
|
||||
{runningProfiles.has(p.id) && (
|
||||
@@ -437,7 +437,7 @@ export function CookieCopyDialog({
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<LuSearch className="absolute left-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<LuSearch className="absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("cookies.copy.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
@@ -449,11 +449,11 @@ export function CookieCopyDialog({
|
||||
</div>
|
||||
|
||||
{isLoadingCookies ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<div className="animate-spin size-6 border-2 border-primary border-t-transparent rounded-full" />
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<div className="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-center text-destructive bg-destructive/10 rounded-md">
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-center text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : filteredDomains.length === 0 ? (
|
||||
@@ -463,8 +463,8 @@ export function CookieCopyDialog({
|
||||
: t("cookies.copy.noFound")}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[250px] border rounded-md">
|
||||
<div className="p-2 space-y-1">
|
||||
<ScrollArea className="h-[clamp(150px,35vh,450px)] rounded-md border">
|
||||
<div className="space-y-1 p-2">
|
||||
{filteredDomains.map((domain) => (
|
||||
<DomainRow
|
||||
key={domain.domain}
|
||||
@@ -549,7 +549,7 @@ function DomainRow({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 p-2 hover:bg-accent/50 rounded">
|
||||
<div className="flex items-center gap-2 rounded p-2 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={isAllSelected || isPartial}
|
||||
onCheckedChange={() => {
|
||||
@@ -559,7 +559,7 @@ function DomainRow({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 flex-1 text-left bg-transparent border-none cursor-pointer"
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 border-none bg-transparent text-left"
|
||||
onClick={() => {
|
||||
onToggleExpand(domain.domain);
|
||||
}}
|
||||
@@ -569,21 +569,21 @@ function DomainRow({
|
||||
) : (
|
||||
<LuChevronRight className="size-4" />
|
||||
)}
|
||||
<span className="font-medium">{domain.domain}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="truncate font-medium">{domain.domain}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
({domain.cookie_count})
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="ml-8 pl-2 border-l space-y-1">
|
||||
<div className="ml-8 space-y-1 border-l pl-2">
|
||||
{domain.cookies.map((cookie) => {
|
||||
const isSelected =
|
||||
domainSelection?.cookies.has(cookie.name) ?? false;
|
||||
return (
|
||||
<div
|
||||
key={`${domain.domain}-${cookie.name}`}
|
||||
className="flex items-center gap-2 p-1 text-sm hover:bg-accent/30 rounded"
|
||||
className="flex items-center gap-2 rounded p-1 text-sm hover:bg-accent/30"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected || isAllSelected}
|
||||
|
||||
@@ -390,7 +390,7 @@ export function CookieManagementDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-[min(44rem,calc(100%-4rem))]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("cookies.management.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -409,7 +409,7 @@ export function CookieManagementDialog({
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="import" className="space-y-4 mt-4">
|
||||
<TabsContent value="import" className="mt-4 space-y-4">
|
||||
{!fileContent && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -418,7 +418,7 @@ export function CookieManagementDialog({
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 transition-colors cursor-pointer border-muted-foreground/25 hover:border-muted-foreground/50"
|
||||
className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25 p-8 transition-colors hover:border-muted-foreground/50"
|
||||
onClick={() =>
|
||||
document.getElementById("cookie-file-input")?.click()
|
||||
}
|
||||
@@ -429,8 +429,8 @@ export function CookieManagementDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LuUpload className="size-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
<LuUpload className="mb-4 size-10 text-muted-foreground" />
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t("cookies.management.dropPrompt")}
|
||||
<br />
|
||||
<span className="text-xs">
|
||||
@@ -454,7 +454,7 @@ export function CookieManagementDialog({
|
||||
|
||||
{fileContent && !importResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg">
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/30 p-4">
|
||||
<div>
|
||||
<div className="font-medium">{fileName}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
@@ -481,7 +481,7 @@ export function CookieManagementDialog({
|
||||
|
||||
{importResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-success/10">
|
||||
<div className="rounded-lg bg-success/10 p-4">
|
||||
<div className="font-medium text-success">
|
||||
{t("cookies.management.importedSuccess", {
|
||||
imported: importResult.cookies_imported,
|
||||
@@ -505,7 +505,7 @@ export function CookieManagementDialog({
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="export" className="space-y-3 mt-4">
|
||||
<TabsContent value="export" className="mt-4 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("cookies.export.formatLabel")}</Label>
|
||||
<Select
|
||||
@@ -533,7 +533,7 @@ export function CookieManagementDialog({
|
||||
<Label>
|
||||
{t("cookies.management.cookiesLabel")}{" "}
|
||||
{exportCookieData && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{t("cookies.management.selectionStatus", {
|
||||
selected: selectedExportCount,
|
||||
total: exportCookieData.total_count,
|
||||
@@ -544,7 +544,7 @@ export function CookieManagementDialog({
|
||||
{exportCookieData && exportCookieData.total_count > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={toggleSelectAll}
|
||||
>
|
||||
{selectedExportCount === exportCookieData.total_count
|
||||
@@ -555,16 +555,16 @@ export function CookieManagementDialog({
|
||||
</div>
|
||||
|
||||
{isLoadingExportCookies ? (
|
||||
<div className="flex items-center justify-center h-24">
|
||||
<div className="animate-spin size-5 border-2 border-primary border-t-transparent rounded-full" />
|
||||
<div className="flex h-24 items-center justify-center">
|
||||
<div className="size-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : !exportCookieData || exportCookieData.domains.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground border rounded-md">
|
||||
<div className="rounded-md border p-4 text-center text-sm text-muted-foreground">
|
||||
{t("cookies.management.noCookies")}
|
||||
</div>
|
||||
) : (
|
||||
<FadingScrollArea className="h-[200px]">
|
||||
<div className="p-2 space-y-1">
|
||||
<FadingScrollArea className="h-[clamp(140px,30vh,420px)]">
|
||||
<div className="space-y-1 p-2">
|
||||
{exportCookieData.domains.map((domain) => (
|
||||
<ExportDomainRow
|
||||
key={domain.domain}
|
||||
@@ -629,7 +629,7 @@ function ExportDomainRow({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 p-1.5 hover:bg-accent/50 rounded">
|
||||
<div className="flex items-center gap-2 rounded p-1.5 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={isAllSelected || isPartial}
|
||||
onCheckedChange={() => {
|
||||
@@ -639,7 +639,7 @@ function ExportDomainRow({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 flex-1 text-left text-sm bg-transparent border-none cursor-pointer"
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 border-none bg-transparent text-left text-sm"
|
||||
onClick={() => {
|
||||
onToggleExpand(domain.domain);
|
||||
}}
|
||||
@@ -649,21 +649,21 @@ function ExportDomainRow({
|
||||
) : (
|
||||
<LuChevronRight className="size-3.5" />
|
||||
)}
|
||||
<span className="font-medium truncate">{domain.domain}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
<span className="truncate font-medium">{domain.domain}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
({domain.cookie_count})
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="ml-7 pl-2 border-l space-y-0.5">
|
||||
<div className="ml-7 space-y-0.5 border-l pl-2">
|
||||
{domain.cookies.map((cookie) => {
|
||||
const isSelected =
|
||||
domainSelection?.cookies.has(cookie.name) ?? false;
|
||||
return (
|
||||
<div
|
||||
key={`${domain.domain}-${cookie.name}`}
|
||||
className="flex items-center gap-2 p-1 text-sm hover:bg-accent/30 rounded"
|
||||
className="flex items-center gap-2 rounded p-1 text-sm hover:bg-accent/30"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected || isAllSelected}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import type { ProfileGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -50,8 +51,7 @@ export function CreateGroupDialog({
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to create group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : t("groups.createFailed");
|
||||
const errorMessage = translateBackendError(t, err);
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
@@ -93,7 +93,7 @@ export function CreateGroupDialog({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,8 +14,6 @@ import { GoPlus } from "react-icons/go";
|
||||
import { LuCheck, LuChevronsUpDown, LuLoaderCircle } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -56,15 +54,9 @@ import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { getBrowserIcon } from "@/lib/browser-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
BrowserReleaseTypes,
|
||||
CamoufoxConfig,
|
||||
CamoufoxOS,
|
||||
WayfernConfig,
|
||||
WayfernOS,
|
||||
} from "@/types";
|
||||
import type { BrowserReleaseTypes, WayfernConfig, WayfernOS } from "@/types";
|
||||
|
||||
const getCurrentOS = (): CamoufoxOS => {
|
||||
const getCurrentOS = (): WayfernOS => {
|
||||
if (typeof navigator === "undefined") return "linux";
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
if (platform.includes("win")) return "windows";
|
||||
@@ -86,7 +78,6 @@ interface CreateProfileDialogProps {
|
||||
releaseType: string;
|
||||
proxyId?: string;
|
||||
vpnId?: string;
|
||||
camoufoxConfig?: CamoufoxConfig;
|
||||
wayfernConfig?: WayfernConfig;
|
||||
groupId?: string;
|
||||
extensionGroupId?: string;
|
||||
@@ -105,10 +96,6 @@ interface BrowserOption {
|
||||
}
|
||||
|
||||
const browserOptions: BrowserOption[] = [
|
||||
{
|
||||
value: "camoufox",
|
||||
label: "Camoufox",
|
||||
},
|
||||
{
|
||||
value: "wayfern",
|
||||
label: "Wayfern",
|
||||
@@ -126,28 +113,24 @@ export function CreateProfileDialog({
|
||||
const proxyListboxIdAntiDetect = useId();
|
||||
const proxyListboxIdRegular = useId();
|
||||
const [profileName, setProfileName] = useState("");
|
||||
// Camoufox is deprecated: only Wayfern profiles can be created, so the dialog
|
||||
// opens straight into the Wayfern config step (no browser-selection screen).
|
||||
const [currentStep, setCurrentStep] = useState<
|
||||
"browser-selection" | "browser-config"
|
||||
>("browser-selection");
|
||||
>("browser-config");
|
||||
const [activeTab, setActiveTab] = useState("anti-detect");
|
||||
|
||||
// Browser selection states
|
||||
// Browser selection states. Defaults to Wayfern — the only creatable browser.
|
||||
const [selectedBrowser, setSelectedBrowser] =
|
||||
useState<BrowserTypeString | null>(null);
|
||||
useState<BrowserTypeString>("wayfern");
|
||||
const [selectedProxyId, setSelectedProxyId] = useState<string>();
|
||||
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
|
||||
const [dnsBlocklist, setDnsBlocklist] = useState<string>("");
|
||||
const [launchHook, setLaunchHook] = useState("");
|
||||
|
||||
// Camoufox anti-detect states
|
||||
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>(() => ({
|
||||
geoip: true, // Default to automatic geoip
|
||||
os: getCurrentOS(), // Default to current OS
|
||||
}));
|
||||
|
||||
// Wayfern anti-detect states
|
||||
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>(() => ({
|
||||
os: getCurrentOS() as WayfernOS, // Default to current OS
|
||||
os: getCurrentOS(), // Default to current OS
|
||||
}));
|
||||
|
||||
// Handle browser selection from the initial screen
|
||||
@@ -156,22 +139,23 @@ export function CreateProfileDialog({
|
||||
setCurrentStep("browser-config");
|
||||
};
|
||||
|
||||
// Handle back button
|
||||
const handleBack = () => {
|
||||
setCurrentStep("browser-selection");
|
||||
setSelectedBrowser(null);
|
||||
// Reset the form fields without leaving the Wayfern config step — Camoufox is
|
||||
// deprecated, so there is no browser-selection screen to go back to.
|
||||
const resetForm = () => {
|
||||
setSelectedBrowser("wayfern");
|
||||
setProfileName("");
|
||||
setSelectedProxyId(undefined);
|
||||
setLaunchHook("");
|
||||
};
|
||||
|
||||
// Handle back button
|
||||
const handleBack = () => {
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
setCurrentStep("browser-selection");
|
||||
setSelectedBrowser(null);
|
||||
setProfileName("");
|
||||
setSelectedProxyId(undefined);
|
||||
setLaunchHook("");
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
|
||||
@@ -307,16 +291,15 @@ export function CreateProfileDialog({
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadSupportedBrowsers();
|
||||
// Load downloaded versions for both anti-detect browsers up front so the
|
||||
// selection-screen availability gate is accurate before either is picked.
|
||||
// Load downloaded Wayfern versions up front so the availability gate is
|
||||
// accurate. Camoufox is deprecated and no longer creatable.
|
||||
void loadDownloadedVersions("wayfern");
|
||||
void loadDownloadedVersions("camoufox");
|
||||
// Load release types when a browser is selected
|
||||
if (selectedBrowser) {
|
||||
void loadReleaseTypes(selectedBrowser);
|
||||
}
|
||||
// Check and download GeoIP database if needed for Camoufox or Wayfern
|
||||
if (selectedBrowser === "camoufox" || selectedBrowser === "wayfern") {
|
||||
// Wayfern needs the GeoIP database for fingerprint generation.
|
||||
if (selectedBrowser === "wayfern") {
|
||||
void checkAndDownloadGeoIPDatabase();
|
||||
}
|
||||
}
|
||||
@@ -417,66 +400,34 @@ export function CreateProfileDialog({
|
||||
: undefined;
|
||||
try {
|
||||
if (activeTab === "anti-detect") {
|
||||
// Anti-detect browser - check if Wayfern or Camoufox is selected
|
||||
if (selectedBrowser === "wayfern") {
|
||||
const bestWayfernVersion = getCreatableVersion("wayfern");
|
||||
if (!bestWayfernVersion) {
|
||||
console.error("No Wayfern version available");
|
||||
return;
|
||||
}
|
||||
|
||||
// The fingerprint will be generated at launch time by the Rust backend
|
||||
const finalWayfernConfig = { ...wayfernConfig };
|
||||
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: "wayfern" as BrowserTypeString,
|
||||
version: bestWayfernVersion.version,
|
||||
releaseType: bestWayfernVersion.releaseType,
|
||||
proxyId: resolvedProxyId,
|
||||
vpnId: resolvedVpnId,
|
||||
wayfernConfig: finalWayfernConfig,
|
||||
groupId:
|
||||
selectedGroupId && selectedGroupId !== "__all__"
|
||||
? selectedGroupId
|
||||
: undefined,
|
||||
extensionGroupId: selectedExtensionGroupId,
|
||||
ephemeral,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
launchHook: launchHook.trim() || undefined,
|
||||
password: passwordToSet,
|
||||
});
|
||||
} else {
|
||||
// Default to Camoufox
|
||||
const bestCamoufoxVersion = getCreatableVersion("camoufox");
|
||||
if (!bestCamoufoxVersion) {
|
||||
console.error("No Camoufox version available");
|
||||
return;
|
||||
}
|
||||
|
||||
// The fingerprint will be generated at launch time by the Rust backend
|
||||
// We don't need to generate it here during profile creation
|
||||
const finalCamoufoxConfig = { ...camoufoxConfig };
|
||||
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: "camoufox" as BrowserTypeString,
|
||||
version: bestCamoufoxVersion.version,
|
||||
releaseType: bestCamoufoxVersion.releaseType,
|
||||
proxyId: resolvedProxyId,
|
||||
vpnId: resolvedVpnId,
|
||||
camoufoxConfig: finalCamoufoxConfig,
|
||||
groupId:
|
||||
selectedGroupId && selectedGroupId !== "__all__"
|
||||
? selectedGroupId
|
||||
: undefined,
|
||||
extensionGroupId: selectedExtensionGroupId,
|
||||
ephemeral,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
launchHook: launchHook.trim() || undefined,
|
||||
password: passwordToSet,
|
||||
});
|
||||
// Camoufox is deprecated — only Wayfern anti-detect profiles are created.
|
||||
const bestWayfernVersion = getCreatableVersion("wayfern");
|
||||
if (!bestWayfernVersion) {
|
||||
console.error("No Wayfern version available");
|
||||
return;
|
||||
}
|
||||
|
||||
// The fingerprint will be generated at launch time by the Rust backend
|
||||
const finalWayfernConfig = { ...wayfernConfig };
|
||||
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: "wayfern" as BrowserTypeString,
|
||||
version: bestWayfernVersion.version,
|
||||
releaseType: bestWayfernVersion.releaseType,
|
||||
proxyId: resolvedProxyId,
|
||||
vpnId: resolvedVpnId,
|
||||
wayfernConfig: finalWayfernConfig,
|
||||
groupId:
|
||||
selectedGroupId && selectedGroupId !== "__all__"
|
||||
? selectedGroupId
|
||||
: undefined,
|
||||
extensionGroupId: selectedExtensionGroupId,
|
||||
ephemeral,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
launchHook: launchHook.trim() || undefined,
|
||||
password: passwordToSet,
|
||||
});
|
||||
} else {
|
||||
// Regular browser
|
||||
if (!selectedBrowser) {
|
||||
@@ -519,22 +470,19 @@ export function CreateProfileDialog({
|
||||
// Cancel any ongoing loading
|
||||
loadingBrowserRef.current = null;
|
||||
|
||||
// Reset all states
|
||||
// Reset all states. Stay on the Wayfern config step — Camoufox is
|
||||
// deprecated, so the browser-selection screen is gone.
|
||||
setProfileName("");
|
||||
setCurrentStep("browser-selection");
|
||||
setCurrentStep("browser-config");
|
||||
setActiveTab("anti-detect");
|
||||
setSelectedBrowser(null);
|
||||
setSelectedBrowser("wayfern");
|
||||
setSelectedProxyId(undefined);
|
||||
setLaunchHook("");
|
||||
setReleaseTypes({});
|
||||
setIsLoadingReleaseTypes(false);
|
||||
setReleaseTypesError(null);
|
||||
setCamoufoxConfig({
|
||||
geoip: true, // Reset to automatic geoip
|
||||
os: getCurrentOS(), // Reset to current OS
|
||||
});
|
||||
setWayfernConfig({
|
||||
os: getCurrentOS() as WayfernOS, // Reset to current OS
|
||||
os: getCurrentOS(), // Reset to current OS
|
||||
});
|
||||
setEphemeral(false);
|
||||
setEnablePassword(false);
|
||||
@@ -544,10 +492,6 @@ export function CreateProfileDialog({
|
||||
onClose();
|
||||
};
|
||||
|
||||
const updateCamoufoxConfig = (key: keyof CamoufoxConfig, value: unknown) => {
|
||||
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const updateWayfernConfig = (key: keyof WayfernConfig, value: unknown) => {
|
||||
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
@@ -590,7 +534,7 @@ export function CreateProfileDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="w-[380px] max-w-[380px] max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="flex max-h-[90vh] max-w-[min(48rem,calc(100%-4rem))] flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>
|
||||
{currentStep === "browser-selection"
|
||||
@@ -607,13 +551,13 @@ export function CreateProfileDialog({
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex flex-col flex-1 w-full min-h-0"
|
||||
className="flex min-h-0 w-full flex-1 flex-col"
|
||||
>
|
||||
{/* Tab list hidden - only anti-detect browsers are supported */}
|
||||
|
||||
<ScrollArea className="overflow-y-auto flex-1">
|
||||
<div className="flex flex-col justify-center items-center w-full">
|
||||
<div className="py-4 space-y-6 w-full max-w-md">
|
||||
<ScrollArea className="flex-1 overflow-y-auto">
|
||||
<div className="flex w-full flex-col items-center justify-center">
|
||||
<div className="w-full space-y-6 py-4">
|
||||
{currentStep === "browser-selection" ? (
|
||||
<>
|
||||
<TabsContent value="anti-detect" className="mt-0 space-y-6">
|
||||
@@ -625,10 +569,10 @@ export function CreateProfileDialog({
|
||||
handleBrowserSelect("wayfern");
|
||||
}}
|
||||
disabled={!getCreatableVersion("wayfern")}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
className="flex h-16 w-full items-center justify-start gap-3 border-2 p-4 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex justify-center items-center size-8">
|
||||
<div className="flex size-8 items-center justify-center">
|
||||
{isBrowserCurrentlyDownloading("wayfern") ? (
|
||||
<LuLoaderCircle className="size-6 animate-spin" />
|
||||
) : (
|
||||
@@ -652,46 +596,14 @@ export function CreateProfileDialog({
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Camoufox (Firefox) - Second */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleBrowserSelect("camoufox");
|
||||
}}
|
||||
disabled={!getCreatableVersion("camoufox")}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex justify-center items-center size-8">
|
||||
{isBrowserCurrentlyDownloading("camoufox") ? (
|
||||
<LuLoaderCircle className="size-6 animate-spin" />
|
||||
) : (
|
||||
(() => {
|
||||
const IconComponent =
|
||||
getBrowserIcon("camoufox");
|
||||
return IconComponent ? (
|
||||
<IconComponent className="size-6" />
|
||||
) : null;
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
{t("createProfile.firefoxLabel")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isBrowserCurrentlyDownloading("camoufox")
|
||||
? t("createProfile.downloadingSubtitle")
|
||||
: t("createProfile.firefoxSubtitle")}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
{/* Camoufox is deprecated — no longer offered for new
|
||||
profiles. Only Wayfern can be created. */}
|
||||
|
||||
{!getCreatableVersion("wayfern") &&
|
||||
!getCreatableVersion("camoufox") && (
|
||||
<p className="pt-2 text-sm text-center text-muted-foreground">
|
||||
{t("createProfile.browsersDownloading")}
|
||||
</p>
|
||||
)}
|
||||
{!getCreatableVersion("wayfern") && (
|
||||
<p className="pt-2 text-center text-sm text-muted-foreground">
|
||||
{t("createProfile.browsersDownloading")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -717,10 +629,10 @@ export function CreateProfileDialog({
|
||||
onClick={() => {
|
||||
handleBrowserSelect(browser.value);
|
||||
}}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
className="flex h-16 w-full items-center justify-start gap-3 border-2 p-4 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex justify-center items-center size-8">
|
||||
<div className="flex size-8 items-center justify-center">
|
||||
{IconComponent && (
|
||||
<IconComponent className="size-6" />
|
||||
)}
|
||||
@@ -772,7 +684,7 @@ export function CreateProfileDialog({
|
||||
</div>
|
||||
|
||||
{/* Ephemeral Option */}
|
||||
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
|
||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
id="ephemeral"
|
||||
@@ -785,14 +697,14 @@ export function CreateProfileDialog({
|
||||
{t("profiles.ephemeral")}
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground ml-6">
|
||||
<p className="ml-6 text-sm text-muted-foreground">
|
||||
{t("profiles.ephemeralDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Password Option */}
|
||||
{!ephemeral && (
|
||||
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
|
||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
id="enable-password"
|
||||
@@ -813,7 +725,7 @@ export function CreateProfileDialog({
|
||||
{t("createProfile.passwordProtect.label")}
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground ml-6">
|
||||
<p className="ml-6 text-sm text-muted-foreground">
|
||||
{t("createProfile.passwordProtect.description")}
|
||||
</p>
|
||||
{enablePassword && (
|
||||
@@ -857,15 +769,15 @@ export function CreateProfileDialog({
|
||||
<div className="space-y-6">
|
||||
{/* Wayfern Download Status */}
|
||||
{isLoadingReleaseTypes && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
||||
<div className="flex items-center gap-3 rounded-md border p-3">
|
||||
<div className="size-4 animate-spin rounded-full border-2 border-muted/40 border-t-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("createProfile.version.fetching")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes && releaseTypesError && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-destructive/50 bg-destructive/10">
|
||||
<div className="flex items-center gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
||||
<p className="flex-1 text-sm text-destructive">
|
||||
{releaseTypesError}
|
||||
</p>
|
||||
@@ -884,7 +796,7 @@ export function CreateProfileDialog({
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!getBestAvailableVersion("wayfern") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
|
||||
<div className="flex items-center gap-3 rounded-md border border-warning/50 bg-warning/10 p-3">
|
||||
<p className="text-sm text-warning">
|
||||
{t("createProfile.platformUnavailable", {
|
||||
browser: "Wayfern",
|
||||
@@ -897,7 +809,7 @@ export function CreateProfileDialog({
|
||||
!isBrowserCurrentlyDownloading("wayfern") &&
|
||||
!getCreatableVersion("wayfern") &&
|
||||
getBestAvailableVersion("wayfern") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<div className="flex items-center gap-3 rounded-md border p-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("createProfile.version.needsDownload", {
|
||||
browser: "Wayfern",
|
||||
@@ -928,7 +840,7 @@ export function CreateProfileDialog({
|
||||
!releaseTypesError &&
|
||||
!isBrowserCurrentlyDownloading("wayfern") &&
|
||||
getCreatableVersion("wayfern") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
<div className="rounded-md border p-3 text-sm text-muted-foreground">
|
||||
✓{" "}
|
||||
{t("createProfile.version.available", {
|
||||
browser: "Wayfern",
|
||||
@@ -943,7 +855,7 @@ export function CreateProfileDialog({
|
||||
getCreatableVersion("wayfern") &&
|
||||
!isBrowserVersionAvailable("wayfern") &&
|
||||
getBestAvailableVersion("wayfern") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<div className="flex items-center gap-3 rounded-md border p-3">
|
||||
<p className="flex-1 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"createProfile.version.upgradeAvailable",
|
||||
@@ -975,7 +887,7 @@ export function CreateProfileDialog({
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading("wayfern") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
<div className="rounded-md border p-3 text-sm text-muted-foreground">
|
||||
{t("createProfile.version.downloading", {
|
||||
browser: "Wayfern",
|
||||
version:
|
||||
@@ -996,168 +908,15 @@ export function CreateProfileDialog({
|
||||
profileBrowser="wayfern"
|
||||
/>
|
||||
</div>
|
||||
) : selectedBrowser === "camoufox" ? (
|
||||
// Camoufox Configuration
|
||||
<div className="space-y-6">
|
||||
{/* Camoufox Download Status */}
|
||||
{isLoadingReleaseTypes && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("createProfile.version.fetching")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes && releaseTypesError && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-destructive/50 bg-destructive/10">
|
||||
<p className="flex-1 text-sm text-destructive">
|
||||
{releaseTypesError}
|
||||
</p>
|
||||
<RippleButton
|
||||
onClick={() =>
|
||||
selectedBrowser &&
|
||||
loadReleaseTypes(selectedBrowser)
|
||||
}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.buttons.retry")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
|
||||
<p className="text-sm text-warning">
|
||||
{t("createProfile.platformUnavailable", {
|
||||
browser: "Camoufox",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
!getCreatableVersion("camoufox") &&
|
||||
getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("createProfile.version.needsDownload", {
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
})}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
void handleDownload("camoufox");
|
||||
}}
|
||||
isLoading={isBrowserCurrentlyDownloading(
|
||||
"camoufox",
|
||||
)}
|
||||
size="sm"
|
||||
disabled={isBrowserCurrentlyDownloading(
|
||||
"camoufox",
|
||||
)}
|
||||
>
|
||||
{isBrowserCurrentlyDownloading("camoufox")
|
||||
? t("common.buttons.downloading")
|
||||
: t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
getCreatableVersion("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
✓{" "}
|
||||
{t("createProfile.version.available", {
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getCreatableVersion("camoufox")?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
!releaseTypesError &&
|
||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
getCreatableVersion("camoufox") &&
|
||||
!isBrowserVersionAvailable("camoufox") &&
|
||||
getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<p className="flex-1 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"createProfile.version.upgradeAvailable",
|
||||
{
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
void handleDownload("camoufox");
|
||||
}}
|
||||
isLoading={isBrowserCurrentlyDownloading(
|
||||
"camoufox",
|
||||
)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isBrowserCurrentlyDownloading(
|
||||
"camoufox",
|
||||
)}
|
||||
>
|
||||
{isBrowserCurrentlyDownloading("camoufox")
|
||||
? t("common.buttons.downloading")
|
||||
: t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{t("createProfile.version.downloading", {
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{crossOsUnlocked && (
|
||||
<Alert className="border-warning/50 bg-warning/10">
|
||||
<AlertDescription className="text-sm">
|
||||
{t("createProfile.camoufoxWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SharedCamoufoxConfigForm
|
||||
config={camoufoxConfig}
|
||||
onConfigChange={updateCamoufoxConfig}
|
||||
isCreating
|
||||
browserType="camoufox"
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
profileVersion={
|
||||
getCreatableVersion("camoufox")?.version
|
||||
}
|
||||
profileBrowser="camoufox"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// Regular Browser Configuration (should not happen in anti-detect tab)
|
||||
// Regular Browser Configuration (should not happen in
|
||||
// the anti-detect tab; Camoufox creation is removed).
|
||||
<div className="space-y-4">
|
||||
{selectedBrowser && (
|
||||
<div className="space-y-3">
|
||||
{isLoadingReleaseTypes && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-4 animate-spin rounded-full border-2 border-muted/40 border-t-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("createProfile.version.fetching")}
|
||||
</p>
|
||||
@@ -1165,7 +924,7 @@ export function CreateProfileDialog({
|
||||
)}
|
||||
{!isLoadingReleaseTypes &&
|
||||
releaseTypesError && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-destructive/50 bg-destructive/10">
|
||||
<div className="flex items-center gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
||||
<p className="flex-1 text-sm text-destructive">
|
||||
{releaseTypesError}
|
||||
</p>
|
||||
@@ -1188,7 +947,7 @@ export function CreateProfileDialog({
|
||||
) &&
|
||||
!getCreatableVersion(selectedBrowser) &&
|
||||
getBestAvailableVersion(selectedBrowser) && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"createProfile.version.latestNeedsDownload",
|
||||
@@ -1257,7 +1016,7 @@ export function CreateProfileDialog({
|
||||
|
||||
{/* Proxy / VPN Selection - Always visible */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t("createProfile.proxy.title")}</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
@@ -1265,7 +1024,7 @@ export function CreateProfileDialog({
|
||||
onClick={() => {
|
||||
setShowProxyForm(true);
|
||||
}}
|
||||
className="px-2 h-7 text-xs"
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<GoPlus className="mr-1 size-3" />{" "}
|
||||
{t("createProfile.proxy.addProxy")}
|
||||
@@ -1385,7 +1144,7 @@ export function CreateProfileDialog({
|
||||
/>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
className="mr-1 px-1 py-0 text-[10px] leading-tight"
|
||||
>
|
||||
WG
|
||||
</Badge>
|
||||
@@ -1399,7 +1158,7 @@ export function CreateProfileDialog({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
<div className="flex items-center gap-3 rounded-md border p-3 text-sm text-muted-foreground">
|
||||
{t("createProfile.proxy.noProxiesAvailable")}
|
||||
</div>
|
||||
)}
|
||||
@@ -1526,15 +1285,15 @@ export function CreateProfileDialog({
|
||||
{selectedBrowser && (
|
||||
<div className="space-y-3">
|
||||
{isLoadingReleaseTypes && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-4 animate-spin rounded-full border-2 border-muted/40 border-t-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("createProfile.version.fetching")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingReleaseTypes && releaseTypesError && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-destructive/50 bg-destructive/10">
|
||||
<div className="flex items-center gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
||||
<p className="flex-1 text-sm text-destructive">
|
||||
{releaseTypesError}
|
||||
</p>
|
||||
@@ -1557,7 +1316,7 @@ export function CreateProfileDialog({
|
||||
) &&
|
||||
!getCreatableVersion(selectedBrowser) &&
|
||||
getBestAvailableVersion(selectedBrowser) && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"createProfile.version.latestNeedsDownload",
|
||||
@@ -1624,7 +1383,7 @@ export function CreateProfileDialog({
|
||||
|
||||
{/* Proxy / VPN Selection - Always visible */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t("createProfile.proxy.title")}</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
@@ -1632,7 +1391,7 @@ export function CreateProfileDialog({
|
||||
onClick={() => {
|
||||
setShowProxyForm(true);
|
||||
}}
|
||||
className="px-2 h-7 text-xs"
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<GoPlus className="mr-1 size-3" />{" "}
|
||||
{t("createProfile.proxy.addProxy")}
|
||||
@@ -1752,7 +1511,7 @@ export function CreateProfileDialog({
|
||||
/>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
className="mr-1 px-1 py-0 text-[10px] leading-tight"
|
||||
>
|
||||
WG
|
||||
</Badge>
|
||||
@@ -1766,7 +1525,7 @@ export function CreateProfileDialog({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
<div className="flex items-center gap-3 rounded-md border p-3 text-sm text-muted-foreground">
|
||||
{t("createProfile.proxy.noProxiesAvailable")}
|
||||
</div>
|
||||
)}
|
||||
@@ -1797,7 +1556,7 @@ export function CreateProfileDialog({
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter className="shrink-0 pt-4 border-t">
|
||||
<DialogFooter className="shrink-0 border-t pt-4">
|
||||
{currentStep === "browser-config" ? (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={handleBack}>
|
||||
|
||||
@@ -83,12 +83,7 @@ interface ErrorToastProps extends BaseToastProps {
|
||||
|
||||
interface DownloadToastProps extends BaseToastProps {
|
||||
type: "download";
|
||||
stage?:
|
||||
| "downloading"
|
||||
| "extracting"
|
||||
| "verifying"
|
||||
| "completed"
|
||||
| "downloading (twilight rolling release)";
|
||||
stage?: "downloading" | "extracting" | "verifying" | "completed";
|
||||
progress?: {
|
||||
percentage: number;
|
||||
speed?: string;
|
||||
@@ -111,12 +106,6 @@ interface FetchingToastProps extends BaseToastProps {
|
||||
browserName?: string;
|
||||
}
|
||||
|
||||
interface TwilightUpdateToastProps extends BaseToastProps {
|
||||
type: "twilight-update";
|
||||
browserName?: string;
|
||||
hasUpdate?: boolean;
|
||||
}
|
||||
|
||||
interface SyncProgressToastProps extends BaseToastProps {
|
||||
type: "sync-progress";
|
||||
progress?: {
|
||||
@@ -138,7 +127,6 @@ type ToastProps =
|
||||
| DownloadToastProps
|
||||
| VersionUpdateToastProps
|
||||
| FetchingToastProps
|
||||
| TwilightUpdateToastProps
|
||||
| SyncProgressToastProps;
|
||||
|
||||
function formatBytesCompact(bytes: number): string {
|
||||
@@ -174,38 +162,34 @@ function formatEtaCompact(seconds: number): string {
|
||||
function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return <LuCheckCheck className="shrink-0 size-4 text-foreground" />;
|
||||
return <LuCheckCheck className="size-4 shrink-0 text-foreground" />;
|
||||
case "error":
|
||||
return <LuTriangleAlert className="shrink-0 size-4 text-foreground" />;
|
||||
return <LuTriangleAlert className="size-4 shrink-0 text-foreground" />;
|
||||
case "download":
|
||||
if (stage === "completed") {
|
||||
return <LuCheckCheck className="shrink-0 size-4 text-foreground" />;
|
||||
return <LuCheckCheck className="size-4 shrink-0 text-foreground" />;
|
||||
}
|
||||
return <LuDownload className="shrink-0 size-4 text-foreground" />;
|
||||
return <LuDownload className="size-4 shrink-0 text-foreground" />;
|
||||
|
||||
case "version-update":
|
||||
return (
|
||||
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
||||
<LuRefreshCw className="size-4 shrink-0 animate-spin text-foreground" />
|
||||
);
|
||||
case "fetching":
|
||||
return (
|
||||
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
||||
);
|
||||
case "twilight-update":
|
||||
return (
|
||||
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
||||
<LuRefreshCw className="size-4 shrink-0 animate-spin text-foreground" />
|
||||
);
|
||||
case "sync-progress":
|
||||
return (
|
||||
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
||||
<LuRefreshCw className="size-4 shrink-0 animate-spin text-foreground" />
|
||||
);
|
||||
case "loading":
|
||||
return (
|
||||
<div className="shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
|
||||
<div className="size-4 shrink-0 animate-spin rounded-full border-2 border-foreground border-t-transparent" />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
|
||||
<div className="size-4 shrink-0 animate-spin rounded-full border-2 border-foreground border-t-transparent" />
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -217,18 +201,16 @@ export function UnifiedToast(props: ToastProps) {
|
||||
const progress = "progress" in props ? props.progress : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex items-start p-3 w-96 rounded-lg border shadow-lg bg-card border-border text-card-foreground">
|
||||
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex w-full max-w-md items-start rounded-lg border border-border bg-card p-3 text-card-foreground shadow-lg">
|
||||
<div className="mt-0.5 mr-3">{getToastIcon(type, stage)}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold leading-tight text-foreground">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-sm/tight font-semibold text-foreground">{title}</p>
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
className="ml-2 shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label={t("common.buttons.cancel")}
|
||||
>
|
||||
<LuX className="size-3" />
|
||||
@@ -242,16 +224,17 @@ export function UnifiedToast(props: ToastProps) {
|
||||
"percentage" in progress &&
|
||||
stage === "downloading" && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="flex-1 min-w-0 text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
|
||||
{progress.percentage.toFixed(1)}%
|
||||
{progress.speed && ` • ${progress.speed} MB/s`}
|
||||
{progress.eta && ` • ${progress.eta} remaining`}
|
||||
{progress.eta &&
|
||||
` • ${t("toasts.progress.remaining", { time: progress.eta })}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-1.5">
|
||||
<div className="h-1.5 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className="bg-foreground h-1.5 rounded-full transition-all duration-150"
|
||||
className="h-1.5 rounded-full bg-foreground transition-all duration-150"
|
||||
style={{ width: `${progress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -264,20 +247,21 @@ export function UnifiedToast(props: ToastProps) {
|
||||
"current_browser" in progress && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress.current_browser && (
|
||||
<>Looking for updates for {progress.current_browser}</>
|
||||
)}
|
||||
{progress.current_browser &&
|
||||
t("versionUpdater.toast.lookingForUpdates", {
|
||||
browser: progress.current_browser,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="flex-1 bg-muted rounded-full h-1.5 min-w-0">
|
||||
<div className="h-1.5 min-w-0 flex-1 rounded-full bg-muted">
|
||||
<div
|
||||
className="bg-foreground h-1.5 rounded-full transition-all duration-150"
|
||||
className="h-1.5 rounded-full bg-foreground transition-all duration-150"
|
||||
style={{
|
||||
width: `${(progress.current / progress.total) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-xs text-right whitespace-nowrap text-muted-foreground shrink-0">
|
||||
<span className="w-8 shrink-0 text-right text-xs whitespace-nowrap text-muted-foreground">
|
||||
{progress.current}/{progress.total}
|
||||
</span>
|
||||
</div>
|
||||
@@ -293,7 +277,10 @@ export function UnifiedToast(props: ToastProps) {
|
||||
{progress.phase === "uploading"
|
||||
? t("appUpdate.toast.uploading")
|
||||
: t("appUpdate.toast.downloading")}{" "}
|
||||
{progress.completed_files}/{progress.total_files} files
|
||||
{t("toasts.progress.filesProgress", {
|
||||
completed: progress.completed_files,
|
||||
total: progress.total_files,
|
||||
})}
|
||||
{" \u2022 "}
|
||||
{formatBytesCompact(progress.completed_bytes)} /{" "}
|
||||
{formatBytesCompact(progress.total_bytes)}
|
||||
@@ -304,40 +291,24 @@ export function UnifiedToast(props: ToastProps) {
|
||||
</>
|
||||
)}
|
||||
{progress.eta_seconds > 0 &&
|
||||
progress.completed_files < progress.total_files && (
|
||||
<>
|
||||
{" \u2022 ~"}
|
||||
{formatEtaCompact(progress.eta_seconds)} remaining
|
||||
</>
|
||||
)}
|
||||
progress.completed_files < progress.total_files &&
|
||||
` \u2022 ${t("toasts.progress.remaining", {
|
||||
time: `~${formatEtaCompact(progress.eta_seconds)}`,
|
||||
})}`}
|
||||
</p>
|
||||
{progress.failed_count > 0 && (
|
||||
<p className="text-xs text-destructive mt-0.5">
|
||||
{progress.failed_count} file(s) failed
|
||||
<p className="mt-0.5 text-xs text-destructive">
|
||||
{t("toasts.progress.filesFailed", {
|
||||
count: progress.failed_count,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Twilight update progress */}
|
||||
{type === "twilight-update" && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{"hasUpdate" in props && props.hasUpdate
|
||||
? "New twilight build available for download"
|
||||
: "Checking for twilight updates..."}
|
||||
</p>
|
||||
{props.browserName && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{props.browserName} • Rolling Release
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="mt-1 text-xs leading-tight text-muted-foreground">
|
||||
<p className="mt-1 text-xs/tight text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
@@ -355,11 +326,6 @@ export function UnifiedToast(props: ToastProps) {
|
||||
{t("browserDownload.toast.verifying")}
|
||||
</p>
|
||||
)}
|
||||
{stage === "downloading (twilight rolling release)" && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("browserDownload.toast.downloadingRolling")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{action &&
|
||||
|
||||
@@ -65,7 +65,7 @@ function DataTableActionBar<TData>({
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-6 z-50 mx-auto flex w-fit flex-wrap items-center justify-center gap-2 rounded-md border bg-background p-2 text-foreground shadow-sm",
|
||||
"fixed inset-x-0 bottom-6 z-50 mx-auto flex w-fit max-w-[calc(100%-2rem)] flex-wrap items-center justify-center gap-2 rounded-md border bg-background p-2 text-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -106,7 +106,7 @@ function DataTableActionBarAction({
|
||||
{...props}
|
||||
>
|
||||
{isPending ? (
|
||||
<div className="size-3.5 rounded-full border border-current animate-spin border-t-transparent" />
|
||||
<div className="size-3.5 animate-spin rounded-full border border-current border-t-transparent" />
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
@@ -142,7 +142,7 @@ function DataTableActionBarSelection<TData>({
|
||||
|
||||
return (
|
||||
<div className="flex h-7 items-center rounded-md border pr-1 pl-2.5">
|
||||
<span className="whitespace-nowrap text-xs">
|
||||
<span className="text-xs whitespace-nowrap">
|
||||
{t("dataTableActionBar.selected", {
|
||||
count: table.getFilteredSelectedRowModel().rows.length,
|
||||
})}
|
||||
@@ -164,7 +164,7 @@ function DataTableActionBarSelection<TData>({
|
||||
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-card [&>span]:hidden"
|
||||
>
|
||||
<p>{t("dataTableActionBar.clearSelection")}</p>
|
||||
<kbd className="select-none rounded border bg-background px-1.5 py-px font-mono font-normal text-[0.7rem] text-foreground shadow-xs">
|
||||
<kbd className="rounded border bg-background px-1.5 py-px font-mono text-[0.7rem] font-normal text-foreground shadow-xs select-none">
|
||||
<abbr title={t("common.keys.escape")} className="no-underline">
|
||||
Esc
|
||||
</abbr>
|
||||
|
||||
@@ -19,6 +19,12 @@ interface DeleteConfirmationDialogProps {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmButtonText?: string;
|
||||
confirmButtonVariant?:
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "secondary"
|
||||
| "ghost";
|
||||
isLoading?: boolean;
|
||||
profileIds?: string[];
|
||||
profiles?: { id: string; name: string }[];
|
||||
@@ -31,6 +37,7 @@ export function DeleteConfirmationDialog({
|
||||
title,
|
||||
description,
|
||||
confirmButtonText,
|
||||
confirmButtonVariant = "destructive",
|
||||
isLoading = false,
|
||||
profileIds,
|
||||
profiles = [],
|
||||
@@ -48,16 +55,19 @@ export function DeleteConfirmationDialog({
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
{profileIds && profileIds.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium mb-2">
|
||||
<p className="mb-2 text-sm font-medium">
|
||||
{t("deleteDialog.profilesToDelete")}
|
||||
</p>
|
||||
<div className="bg-muted rounded-md p-3 max-h-32 overflow-y-auto">
|
||||
<div className="max-h-32 overflow-y-auto rounded-md bg-muted p-3">
|
||||
<ul className="space-y-1">
|
||||
{profileIds.map((id) => {
|
||||
const profile = profiles.find((p) => p.id === id);
|
||||
const displayName = profile ? profile.name : id;
|
||||
return (
|
||||
<li key={id} className="text-sm text-muted-foreground">
|
||||
<li
|
||||
key={id}
|
||||
className="truncate text-sm text-muted-foreground"
|
||||
>
|
||||
• {displayName}
|
||||
</li>
|
||||
);
|
||||
@@ -76,7 +86,7 @@ export function DeleteConfirmationDialog({
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
variant="destructive"
|
||||
variant={confirmButtonVariant}
|
||||
onClick={() => void handleConfirm()}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import type { BrowserProfile, ProfileGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -97,8 +98,7 @@ export function DeleteGroupDialog({
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : t("groups.deleteFailed");
|
||||
const errorMessage = translateBackendError(t, err);
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
@@ -136,10 +136,10 @@ export function DeleteGroupDialog({
|
||||
count: associatedProfiles.length,
|
||||
})}
|
||||
</Label>
|
||||
<ScrollArea className="h-32 w-full border rounded-md p-3">
|
||||
<ScrollArea className="max-h-[min(8rem,25vh)] w-full overflow-y-auto rounded-md border p-3">
|
||||
<div className="space-y-1">
|
||||
{associatedProfiles.map((profile) => (
|
||||
<div key={profile.id} className="text-sm">
|
||||
<div key={profile.id} className="truncate text-sm">
|
||||
• {profile.name}
|
||||
</div>
|
||||
))}
|
||||
@@ -184,7 +184,7 @@ export function DeleteGroupDialog({
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function DnsBlocklistDialog({
|
||||
{t("dnsBlocklist.settingsDescription")}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="max-h-[40vh] min-h-0 space-y-3 overflow-y-auto">
|
||||
{statuses.map((status) => (
|
||||
<div
|
||||
key={status.level}
|
||||
@@ -100,18 +100,18 @@ export function DnsBlocklistDialog({
|
||||
</span>
|
||||
{status.is_cached ? (
|
||||
status.is_fresh ? (
|
||||
<Badge variant="default" className="text-[10px] px-1.5">
|
||||
<Badge variant="default" className="px-1.5 text-[10px]">
|
||||
{t("dnsBlocklist.fresh")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5">
|
||||
<Badge variant="secondary" className="px-1.5 text-[10px]">
|
||||
{t("dnsBlocklist.stale")}
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 text-muted-foreground"
|
||||
className="px-1.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{t("dnsBlocklist.notCached")}
|
||||
</Badge>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import type { ProfileGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -61,8 +62,7 @@ export function EditGroupDialog({
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to update group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : t("groups.updateFailed");
|
||||
const errorMessage = translateBackendError(t, err);
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
@@ -103,7 +103,7 @@ export function EditGroupDialog({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -110,8 +110,8 @@ export function ExtensionGroupAssignmentDialog({
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("extensions.assignTitle")}:</Label>
|
||||
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
<div className="max-h-[min(8rem,20vh)] overflow-y-auto rounded-md bg-muted p-3">
|
||||
<ul className="space-y-1 text-sm">
|
||||
{selectedProfiles.map((profileId) => {
|
||||
const profile = profiles.find(
|
||||
(p: BrowserProfile) => p.id === profileId,
|
||||
@@ -160,7 +160,7 @@ export function ExtensionGroupAssignmentDialog({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Extension, ExtensionGroup } from "@/types";
|
||||
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
@@ -654,7 +655,7 @@ export function ExtensionManagementDialog({
|
||||
const hasFirefox = compat.includes("firefox");
|
||||
if (!hasChromium && !hasFirefox) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{hasChromium && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -752,7 +753,7 @@ export function ExtensionManagementDialog({
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
className="h-auto cursor-pointer justify-start p-0 text-left font-semibold"
|
||||
>
|
||||
{t("common.labels.name")}
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
@@ -763,13 +764,14 @@ export function ExtensionManagementDialog({
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm font-medium truncate min-w-0 block">
|
||||
<span className="block min-w-0 truncate text-sm font-medium">
|
||||
{row.original.name}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "compat",
|
||||
size: 56,
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) =>
|
||||
@@ -784,7 +786,7 @@ export function ExtensionManagementDialog({
|
||||
const ext = row.original;
|
||||
const syncDot = getSyncStatusDot(ext, extSyncStatus[ext.id], t);
|
||||
return (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
@@ -799,7 +801,7 @@ export function ExtensionManagementDialog({
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center shrink-0">
|
||||
<span className="inline-flex shrink-0 items-center">
|
||||
<AnimatedSwitch
|
||||
checked={ext.sync_enabled}
|
||||
onCheckedChange={() => void handleToggleExtSync(ext)}
|
||||
@@ -821,12 +823,13 @@ export function ExtensionManagementDialog({
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 80,
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const ext = row.original;
|
||||
return (
|
||||
<div className="flex gap-0.5 justify-end shrink-0">
|
||||
<div className="flex shrink-0 justify-end gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -924,7 +927,7 @@ export function ExtensionManagementDialog({
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
className="h-auto cursor-pointer justify-start p-0 text-left font-semibold"
|
||||
>
|
||||
{t("common.labels.name")}
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
@@ -935,13 +938,14 @@ export function ExtensionManagementDialog({
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium text-sm truncate min-w-0 block">
|
||||
<span className="block min-w-0 truncate text-sm font-medium">
|
||||
{row.original.name}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "extensions",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
@@ -952,7 +956,7 @@ export function ExtensionManagementDialog({
|
||||
const visibleExts = groupExts.slice(0, MAX_VISIBLE_ICONS);
|
||||
const overflowCount = groupExts.length - MAX_VISIBLE_ICONS;
|
||||
return (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{visibleExts.map((ext) => (
|
||||
<Tooltip key={ext.id}>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -968,7 +972,7 @@ export function ExtensionManagementDialog({
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs h-5 px-1.5 shrink-0"
|
||||
className="h-5 shrink-0 px-1.5 text-xs"
|
||||
>
|
||||
+{overflowCount}
|
||||
</Badge>
|
||||
@@ -985,7 +989,7 @@ export function ExtensionManagementDialog({
|
||||
</Tooltip>
|
||||
)}
|
||||
{groupExts.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="min-w-0 truncate text-xs text-muted-foreground">
|
||||
{t("extensions.noExtensionsInGroup")}
|
||||
</span>
|
||||
)}
|
||||
@@ -1006,7 +1010,7 @@ export function ExtensionManagementDialog({
|
||||
t,
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
@@ -1021,7 +1025,7 @@ export function ExtensionManagementDialog({
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center shrink-0">
|
||||
<span className="inline-flex shrink-0 items-center">
|
||||
<AnimatedSwitch
|
||||
checked={group.sync_enabled}
|
||||
onCheckedChange={() => void handleToggleGroupSync(group)}
|
||||
@@ -1043,12 +1047,13 @@ export function ExtensionManagementDialog({
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 80,
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const group = row.original;
|
||||
return (
|
||||
<div className="flex gap-0.5 justify-end shrink-0">
|
||||
<div className="flex shrink-0 justify-end gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -1111,7 +1116,7 @@ export function ExtensionManagementDialog({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="flex max-h-[90vh] max-w-[min(80rem,calc(100%-4rem))] flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
@@ -1125,15 +1130,15 @@ export function ExtensionManagementDialog({
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||
<div className="@container relative flex min-h-0 w-full flex-1 flex-col">
|
||||
{limitedMode && (
|
||||
<>
|
||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-linear-to-r from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-linear-to-l from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 top-0 h-6 bg-linear-to-b from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-linear-to-t from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-[3]">
|
||||
<div className="absolute inset-0 z-1 bg-background/30 backdrop-blur-[6px]" />
|
||||
<div className="absolute inset-y-0 left-0 z-2 w-6 bg-linear-to-r from-background to-transparent" />
|
||||
<div className="absolute inset-y-0 right-0 z-2 w-6 bg-linear-to-l from-background to-transparent" />
|
||||
<div className="absolute inset-x-0 top-0 z-2 h-6 bg-linear-to-b from-background to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 z-2 h-6 bg-linear-to-t from-background to-transparent" />
|
||||
<div className="absolute inset-0 z-3 flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
||||
<ProBadge />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
@@ -1148,9 +1153,9 @@ export function ExtensionManagementDialog({
|
||||
key={initialTab}
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "extensions" | "groups")}
|
||||
className="flex-1 min-h-0 flex flex-col"
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 shrink-0">
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger
|
||||
value="extensions"
|
||||
@@ -1170,41 +1175,59 @@ export function ExtensionManagementDialog({
|
||||
</AnimatedTabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeTab === "extensions" && (
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={limitedMode}
|
||||
onClick={() =>
|
||||
document.getElementById("ext-file-input")?.click()
|
||||
}
|
||||
>
|
||||
<LuUpload className="size-4" />
|
||||
{t("extensions.upload")}
|
||||
</RippleButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={limitedMode}
|
||||
onClick={() =>
|
||||
document.getElementById("ext-file-input")?.click()
|
||||
}
|
||||
aria-label={t("extensions.upload")}
|
||||
>
|
||||
<LuUpload className="size-4" />
|
||||
<span className="hidden @2xl:inline">
|
||||
{t("extensions.upload")}
|
||||
</span>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("extensions.upload")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{activeTab === "groups" && (
|
||||
<RippleButton
|
||||
size="sm"
|
||||
disabled={limitedMode}
|
||||
onClick={() => setShowCreateGroup(true)}
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
{t("extensions.newGroup")}
|
||||
</RippleButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
disabled={limitedMode}
|
||||
onClick={() => setShowCreateGroup(true)}
|
||||
aria-label={t("extensions.newGroup")}
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
<span className="hidden @2xl:inline">
|
||||
{t("extensions.newGroup")}
|
||||
</span>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("extensions.newGroup")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notice */}
|
||||
<div className="rounded-md bg-muted/50 p-3 text-sm text-muted-foreground mt-4 shrink-0">
|
||||
<div className="mt-4 shrink-0 rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
|
||||
{t("extensions.managedNotice")}
|
||||
</div>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="extensions"
|
||||
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
|
||||
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<Input
|
||||
id="ext-file-input"
|
||||
type="file"
|
||||
@@ -1267,14 +1290,20 @@ export function ExtensionManagementDialog({
|
||||
</div>
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className="flex-1 min-h-0"
|
||||
className={cn(
|
||||
"min-h-0 flex-1",
|
||||
selectedExtensions.length > 0 && "pb-16",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--scroll-fade-top-offset": "32px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<Table
|
||||
className="w-full table-fixed"
|
||||
containerClassName="overflow-visible"
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
{extTable.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
@@ -1282,10 +1311,14 @@ export function ExtensionManagementDialog({
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width: header.column.columnDef.size
|
||||
? `${header.column.getSize()}px`
|
||||
: undefined,
|
||||
width:
|
||||
header.column.id === "name"
|
||||
? undefined
|
||||
: `${header.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
header.column.id === "name" && "max-w-0",
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
@@ -1308,10 +1341,14 @@ export function ExtensionManagementDialog({
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.columnDef.size
|
||||
? `${cell.column.getSize()}px`
|
||||
: undefined,
|
||||
width:
|
||||
cell.column.id === "name"
|
||||
? undefined
|
||||
: `${cell.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
cell.column.id === "name" && "max-w-0",
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
@@ -1330,12 +1367,12 @@ export function ExtensionManagementDialog({
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="groups"
|
||||
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
|
||||
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
{/* Create group form */}
|
||||
{showCreateGroup && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={newGroupName}
|
||||
onChange={(e) => {
|
||||
@@ -1374,14 +1411,20 @@ export function ExtensionManagementDialog({
|
||||
</div>
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className="flex-1 min-h-0"
|
||||
className={cn(
|
||||
"min-h-0 flex-1",
|
||||
selectedGroups.length > 0 && "pb-16",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--scroll-fade-top-offset": "32px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<Table
|
||||
className="w-full table-fixed"
|
||||
containerClassName="overflow-visible"
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
{groupTable.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
@@ -1389,10 +1432,14 @@ export function ExtensionManagementDialog({
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width: header.column.columnDef.size
|
||||
? `${header.column.getSize()}px`
|
||||
: undefined,
|
||||
width:
|
||||
header.column.id === "name"
|
||||
? undefined
|
||||
: `${header.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
header.column.id === "name" && "max-w-0",
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
@@ -1415,10 +1462,14 @@ export function ExtensionManagementDialog({
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.columnDef.size
|
||||
? `${cell.column.getSize()}px`
|
||||
: undefined,
|
||||
width:
|
||||
cell.column.id === "name"
|
||||
? undefined
|
||||
: `${cell.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
cell.column.id === "name" && "max-w-0",
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
@@ -1458,7 +1509,7 @@ export function ExtensionManagementDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="flex max-h-[90vh] max-w-lg flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("extensions.editGroup")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -1466,7 +1517,7 @@ export function ExtensionManagementDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="overflow-y-auto flex-1 -mx-6 px-6">
|
||||
<ScrollArea className="-mx-6 flex-1 overflow-y-auto px-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("common.labels.name")}</Label>
|
||||
@@ -1511,11 +1562,11 @@ export function ExtensionManagementDialog({
|
||||
<div className="space-y-2">
|
||||
<Label>{t("extensions.groupExtensions")}</Label>
|
||||
{editGroupExtensionIds.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-2">
|
||||
<div className="py-2 text-sm text-muted-foreground">
|
||||
{t("extensions.noExtensionsInGroup")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-[200px] overflow-y-auto">
|
||||
<div className="max-h-[min(40vh,320px)] space-y-1 overflow-y-auto">
|
||||
{editGroupExtensionIds.map((extId) => {
|
||||
const ext = extensions.find((e) => e.id === extId);
|
||||
if (!ext) return null;
|
||||
@@ -1525,14 +1576,14 @@ export function ExtensionManagementDialog({
|
||||
className="flex items-center gap-2 rounded-md border px-2 py-1.5"
|
||||
>
|
||||
{renderExtensionIcon(ext, "sm")}
|
||||
<span className="text-sm flex-1 truncate min-w-0">
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{ext.name}
|
||||
</span>
|
||||
{renderCompatIcons(ext.browser_compatibility)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-6 p-0 shrink-0"
|
||||
className="size-6 shrink-0 p-0"
|
||||
onClick={() => {
|
||||
setEditGroupExtensionIds((prev) =>
|
||||
prev.filter((id) => id !== extId),
|
||||
@@ -1582,7 +1633,7 @@ export function ExtensionManagementDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="flex max-h-[90vh] max-w-lg flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("extensions.editExtension")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -1590,7 +1641,7 @@ export function ExtensionManagementDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="overflow-y-auto flex-1 -mx-6 px-6">
|
||||
<ScrollArea className="-mx-6 flex-1 overflow-y-auto px-6">
|
||||
{editingExtension && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -1608,11 +1659,11 @@ export function ExtensionManagementDialog({
|
||||
</div>
|
||||
|
||||
{/* Metadata from manifest.json */}
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<Label className="text-xs tracking-wide text-muted-foreground uppercase">
|
||||
{t("extensions.metadata")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1.5 text-sm">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-sm">
|
||||
{editingExtension.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
@@ -1660,7 +1711,7 @@ export function ExtensionManagementDialog({
|
||||
href={editingExtension.homepage_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline flex items-center gap-1 truncate"
|
||||
className="flex min-w-0 items-center gap-1 text-primary hover:underline"
|
||||
>
|
||||
<span className="truncate">
|
||||
{editingExtension.homepage_url}
|
||||
@@ -1673,7 +1724,7 @@ export function ExtensionManagementDialog({
|
||||
!editingExtension.author &&
|
||||
!editingExtension.description &&
|
||||
!editingExtension.homepage_url && (
|
||||
<span className="col-span-2 text-muted-foreground text-xs">
|
||||
<span className="col-span-2 text-xs text-muted-foreground">
|
||||
{t("extensions.noMetadata")}
|
||||
</span>
|
||||
)}
|
||||
@@ -1683,7 +1734,7 @@ export function ExtensionManagementDialog({
|
||||
{/* Re-upload */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("extensions.reupload")}</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1691,7 +1742,7 @@ export function ExtensionManagementDialog({
|
||||
document.getElementById("ext-edit-file-input")?.click()
|
||||
}
|
||||
>
|
||||
<LuUpload className="size-3 mr-1" />
|
||||
<LuUpload className="mr-1 size-3" />
|
||||
{t("extensions.selectFile")}
|
||||
</RippleButton>
|
||||
<input
|
||||
@@ -1702,7 +1753,7 @@ export function ExtensionManagementDialog({
|
||||
onChange={handleEditFileSelect}
|
||||
/>
|
||||
{pendingUpdateFile && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||
<span className="max-w-[200px] truncate text-xs text-muted-foreground">
|
||||
{pendingUpdateFile.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -134,8 +134,8 @@ export function GroupAssignmentDialog({
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("groupAssignment.selectedProfilesLabel")}</Label>
|
||||
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
<div className="max-h-[min(8rem,20vh)] overflow-y-auto rounded-md bg-muted p-3">
|
||||
<ul className="space-y-1 text-sm">
|
||||
{selectedProfiles.map((profileId) => {
|
||||
// Find the profile name for display
|
||||
const profile = profiles.find(
|
||||
@@ -153,7 +153,7 @@ export function GroupAssignmentDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="group-select">
|
||||
{t("groupAssignment.assignGroupLabel")}
|
||||
</Label>
|
||||
@@ -198,7 +198,7 @@ export function GroupAssignmentDialog({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { GroupWithCount } from "@/types";
|
||||
|
||||
interface GroupBadgesProps {
|
||||
selectedGroupId: string | null;
|
||||
onGroupSelect: (groupId: string) => void;
|
||||
refreshTrigger?: number;
|
||||
groups: GroupWithCount[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function GroupBadges({
|
||||
selectedGroupId,
|
||||
onGroupSelect,
|
||||
groups,
|
||||
isLoading,
|
||||
}: GroupBadgesProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftFade, setShowLeftFade] = useState(false);
|
||||
const [showRightFade, setShowRightFade] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef<{ x: number; scrollLeft: number } | null>(null);
|
||||
const hasMovedRef = useRef(false);
|
||||
const clickBlockedRef = useRef(false);
|
||||
|
||||
const checkScrollPosition = useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = container;
|
||||
setShowLeftFade(scrollLeft > 0);
|
||||
setShowRightFade(scrollLeft < scrollWidth - clientWidth - 1);
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
dragStartRef.current = {
|
||||
x: e.clientX,
|
||||
scrollLeft: container.scrollLeft,
|
||||
};
|
||||
hasMovedRef.current = false;
|
||||
setIsDragging(true);
|
||||
container.style.cursor = "grabbing";
|
||||
container.style.userSelect = "none";
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!isDragging || !dragStartRef.current) return;
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const deltaX = e.clientX - dragStartRef.current.x;
|
||||
const distance = Math.abs(deltaX);
|
||||
|
||||
if (distance > 5) {
|
||||
hasMovedRef.current = true;
|
||||
}
|
||||
|
||||
container.scrollLeft = dragStartRef.current.scrollLeft - deltaX;
|
||||
checkScrollPosition();
|
||||
},
|
||||
[isDragging, checkScrollPosition],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
container.style.cursor = "";
|
||||
container.style.userSelect = "";
|
||||
}
|
||||
|
||||
clickBlockedRef.current = hasMovedRef.current;
|
||||
setIsDragging(false);
|
||||
dragStartRef.current = null;
|
||||
|
||||
setTimeout(() => {
|
||||
hasMovedRef.current = false;
|
||||
clickBlockedRef.current = false;
|
||||
}, 100);
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}
|
||||
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
checkScrollPosition();
|
||||
container.addEventListener("scroll", checkScrollPosition);
|
||||
const resizeObserver = new ResizeObserver(checkScrollPosition);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", checkScrollPosition);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [checkScrollPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (groups.length === 0) {
|
||||
setShowLeftFade(false);
|
||||
setShowRightFade(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
checkScrollPosition();
|
||||
});
|
||||
});
|
||||
}, [groups, checkScrollPosition]);
|
||||
|
||||
if (isLoading && !groups.length) {
|
||||
return (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<div className="flex items-center gap-2 px-4.5 py-1.5 text-xs">
|
||||
{t("groups.loading")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mb-4">
|
||||
{showLeftFade && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 bg-linear-to-r from-background to-transparent pointer-events-none z-10" />
|
||||
)}
|
||||
{showRightFade && (
|
||||
<div className="absolute right-0 top-0 bottom-0 w-8 bg-linear-to-l from-background to-transparent pointer-events-none z-10" />
|
||||
)}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
role="region"
|
||||
aria-label={t("groups.profileGroupsAriaLabel")}
|
||||
className={`flex gap-2 overflow-x-auto pb-2 -mb-2 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
|
||||
onScroll={checkScrollPosition}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<Badge
|
||||
key={group.id}
|
||||
variant={selectedGroupId === group.id ? "default" : "secondary"}
|
||||
className="flex gap-2 items-center px-3 py-1 transition-colors cursor-pointer dark:hover:bg-primary/60 hover:bg-primary/80 shrink-0"
|
||||
onClick={(e) => {
|
||||
if (hasMovedRef.current || clickBlockedRef.current) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
onGroupSelect(
|
||||
selectedGroupId === group.id ? "default" : group.id,
|
||||
);
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{group.name}</span>
|
||||
<span className="bg-background/20 text-xs px-1.5 py-0.5 rounded-sm">
|
||||
{group.count}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { GroupWithCount, ProfileGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -326,7 +327,7 @@ export function GroupManagementDialog({
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
className="h-auto cursor-pointer justify-start p-0 text-left font-semibold"
|
||||
>
|
||||
{t("common.labels.name")}
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
@@ -345,7 +346,7 @@ export function GroupManagementDialog({
|
||||
groupSyncErrors[group.id],
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<div className="flex min-w-0 items-center gap-2 font-medium">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
@@ -358,8 +359,8 @@ export function GroupManagementDialog({
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<LuFolder className="size-4 text-muted-foreground" />
|
||||
{group.name}
|
||||
<LuFolder className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{group.name}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -494,9 +495,15 @@ export function GroupManagementDialog({
|
||||
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"));
|
||||
const firstRejection = results.find((r) => r.status === "rejected") as
|
||||
| PromiseRejectedResult
|
||||
| undefined;
|
||||
if (firstRejection) {
|
||||
showErrorToast(
|
||||
parseBackendError(firstRejection.reason)
|
||||
? translateBackendError(t, firstRejection.reason)
|
||||
: t("groups.deleteFailed"),
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(t("groups.deleteSuccess"));
|
||||
}
|
||||
@@ -506,9 +513,7 @@ export function GroupManagementDialog({
|
||||
onGroupManagementComplete();
|
||||
} catch (err) {
|
||||
console.error("Bulk group delete failed:", err);
|
||||
showErrorToast(
|
||||
err instanceof Error ? err.message : t("groups.deleteFailed"),
|
||||
);
|
||||
showErrorToast(translateBackendError(t, err));
|
||||
} finally {
|
||||
setIsBulkDeleting(false);
|
||||
}
|
||||
@@ -552,7 +557,7 @@ export function GroupManagementDialog({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="flex max-h-[90vh] max-w-[min(60rem,calc(100%-4rem))] flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("groups.management")}</DialogTitle>
|
||||
@@ -562,7 +567,7 @@ export function GroupManagementDialog({
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col gap-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-base font-semibold">
|
||||
@@ -577,7 +582,7 @@ export function GroupManagementDialog({
|
||||
onClick={() => {
|
||||
setCreateDialogOpen(true);
|
||||
}}
|
||||
className="flex gap-2 items-center shrink-0"
|
||||
className="flex shrink-0 items-center gap-2"
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
{t("proxies.management.create")}
|
||||
@@ -585,7 +590,7 @@ export function GroupManagementDialog({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -601,14 +606,20 @@ export function GroupManagementDialog({
|
||||
</div>
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className="flex-1 min-h-0"
|
||||
className={cn(
|
||||
"min-h-0 flex-1",
|
||||
selectedGroupsForBulk.length > 0 && "pb-16",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--scroll-fade-top-offset": "32px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<Table
|
||||
className="w-full table-fixed"
|
||||
containerClassName="overflow-visible"
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
@@ -616,10 +627,14 @@ export function GroupManagementDialog({
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width: header.column.columnDef.size
|
||||
? `${header.column.getSize()}px`
|
||||
: undefined,
|
||||
width:
|
||||
header.column.id === "name"
|
||||
? undefined
|
||||
: `${header.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
header.column.id === "name" && "max-w-0",
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
@@ -642,10 +657,14 @@ export function GroupManagementDialog({
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.columnDef.size
|
||||
? `${cell.column.getSize()}px`
|
||||
: undefined,
|
||||
width:
|
||||
cell.column.id === "name"
|
||||
? undefined
|
||||
: `${cell.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
cell.column.id === "name" && "max-w-0",
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
|
||||
@@ -131,6 +131,16 @@ const HomeHeader = ({
|
||||
[clearHold],
|
||||
);
|
||||
|
||||
const handleDoubleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isTextInputTarget(e.target)) return;
|
||||
if (e.target instanceof Element && e.target.closest("button")) return;
|
||||
clearHold();
|
||||
void getCurrentWindow().toggleMaximize();
|
||||
},
|
||||
[clearHold],
|
||||
);
|
||||
|
||||
// Horizontal scroll fades for the group filter strip — when the user
|
||||
// has more groups than fit, the right edge fades to hint at overflow.
|
||||
const groupsScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -156,43 +166,45 @@ const HomeHeader = ({
|
||||
const isWindows = platform === "windows";
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: titlebar drag surface; the interactive controls inside are real buttons/inputs
|
||||
<div
|
||||
ref={dragRootRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
className={cn(
|
||||
"flex items-center gap-2 h-11 pl-3 border-b border-border bg-card select-none",
|
||||
// Windows: WindowDragArea renders two 44px native-style controls
|
||||
// (minimize + close) fixed at top-right with z-50, total 88px wide.
|
||||
// Reserve 100px on the right edge so the "+ New" button and search
|
||||
// input clear them with a few pixels of breathing room — issues
|
||||
// #358, #361, #362 all reported the same overlap before this fix.
|
||||
isWindows ? "pr-[100px]" : "pr-3",
|
||||
"flex h-11 items-center gap-2 border-b border-border bg-card pl-3 select-none",
|
||||
// Windows: WindowDragArea renders three 44px native-style controls
|
||||
// (minimize + maximize/restore + close) fixed at top-right with
|
||||
// z-50, total 132px wide. Reserve 144px on the right edge so the
|
||||
// "+ New" button and search input clear them with a few pixels of
|
||||
// breathing room and never sit underneath the controls.
|
||||
isWindows ? "pr-[144px]" : "pr-3",
|
||||
)}
|
||||
>
|
||||
{isMacOS && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex items-center gap-[7px] mr-1 shrink-0"
|
||||
className="mr-1 flex shrink-0 items-center gap-[7px]"
|
||||
>
|
||||
{/* Reserve space for the macOS native traffic lights — the OS draws
|
||||
the colored buttons here through the transparent titlebar. */}
|
||||
<div className="w-[11px] h-[11px] rounded-full" />
|
||||
<div className="w-[11px] h-[11px] rounded-full" />
|
||||
<div className="w-[11px] h-[11px] rounded-full" />
|
||||
<div className="size-[11px] rounded-full" />
|
||||
<div className="size-[11px] rounded-full" />
|
||||
<div className="size-[11px] rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pageTitle ? (
|
||||
<span className="text-xs font-semibold text-card-foreground ml-2">
|
||||
<span className="ml-2 text-xs font-semibold text-card-foreground">
|
||||
{pageTitle}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{showProfileToolbar && (
|
||||
<div className="relative flex-1 min-w-0 flex items-center">
|
||||
<div className="relative flex min-w-0 flex-1 items-center">
|
||||
{groupsFadeLeft && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -205,14 +217,14 @@ const HomeHeader = ({
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
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"
|
||||
className="absolute top-1/2 left-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<LuChevronLeft className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
ref={groupsScrollRef}
|
||||
className="flex items-center gap-3 ml-2 overflow-x-auto scroll-smooth [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
className="ml-2 flex scrollbar-none items-center gap-3 overflow-x-auto scroll-smooth [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
style={{
|
||||
paddingLeft: groupsFadeLeft ? 22 : 0,
|
||||
paddingRight: groupsFadeRight ? 22 : 0,
|
||||
@@ -229,9 +241,9 @@ const HomeHeader = ({
|
||||
onGroupSelect(ALL_FILTER_ID);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 h-7 px-1 text-xs transition-colors duration-100 shrink-0",
|
||||
"flex h-7 shrink-0 items-center gap-1.5 px-1 text-xs transition-colors duration-100",
|
||||
active
|
||||
? "text-foreground font-medium"
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -248,17 +260,18 @@ const HomeHeader = ({
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
title={group.name}
|
||||
onClick={() => {
|
||||
onGroupSelect(active ? ALL_FILTER_ID : group.id);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 h-7 px-1 text-xs transition-colors duration-100 shrink-0",
|
||||
"flex h-7 shrink-0 items-center gap-1.5 px-1 text-xs transition-colors duration-100",
|
||||
active
|
||||
? "text-foreground font-medium"
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span>{group.name}</span>
|
||||
<span className="max-w-40 truncate">{group.name}</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{group.count}
|
||||
</span>
|
||||
@@ -278,7 +291,7 @@ const HomeHeader = ({
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
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"
|
||||
className="absolute top-1/2 right-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<LuChevronRight className="size-3" />
|
||||
</button>
|
||||
@@ -297,16 +310,16 @@ const HomeHeader = ({
|
||||
onChange={(e) => {
|
||||
onSearchQueryChange(e.target.value);
|
||||
}}
|
||||
className="pr-7 pl-8 w-52 h-7 text-xs"
|
||||
className="h-7 w-36 pr-7 pl-8 text-xs min-[860px]:w-52"
|
||||
/>
|
||||
<LuSearch className="absolute left-2.5 top-1/2 size-3.5 transform -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||
<LuSearch className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 transform text-muted-foreground" />
|
||||
{searchQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSearchQueryChange("");
|
||||
}}
|
||||
className="absolute right-1.5 top-1/2 p-0.5 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
|
||||
className="absolute top-1/2 right-1.5 -translate-y-1/2 transform rounded-sm p-0.5 transition-colors hover:bg-accent"
|
||||
aria-label={t("header.clearSearch")}
|
||||
>
|
||||
<LuX className="size-3.5 text-muted-foreground hover:text-foreground" />
|
||||
@@ -325,7 +338,7 @@ const HomeHeader = ({
|
||||
onClick={() => {
|
||||
onCreateProfileDialogOpen(true);
|
||||
}}
|
||||
className="flex gap-1.5 items-center h-7 px-2.5 text-xs"
|
||||
className="flex h-7 items-center gap-1.5 px-2.5 text-xs"
|
||||
>
|
||||
<GoPlus className="size-3.5" />
|
||||
{t("header.newProfile")}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { FaFolder } from "react-icons/fa";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AnimatedTabs,
|
||||
@@ -34,9 +33,10 @@ import {
|
||||
import { WayfernConfigForm } from "@/components/wayfern-config-form";
|
||||
import { useBrowserSupport } from "@/hooks/use-browser-support";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CamoufoxConfig, DetectedProfile, WayfernConfig } from "@/types";
|
||||
import type { DetectedProfile, WayfernConfig } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
const getMappedBrowser = (browser: string): "camoufox" | "wayfern" => {
|
||||
@@ -70,7 +70,6 @@ export function ImportProfileDialog({
|
||||
const [currentStep, setCurrentStep] = useState<"select" | "configure">(
|
||||
"select",
|
||||
);
|
||||
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({});
|
||||
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>({});
|
||||
const [selectedProxyId, setSelectedProxyId] = useState<string | undefined>();
|
||||
|
||||
@@ -91,7 +90,11 @@ export function ImportProfileDialog({
|
||||
useBrowserSupport();
|
||||
const { storedProxies } = useProxyEvents();
|
||||
|
||||
const importableBrowsers = supportedBrowsers;
|
||||
// Firefox-based browsers map to the deprecated Camoufox and can no longer be
|
||||
// imported (the backend rejects them); only offer Chromium-family sources.
|
||||
const importableBrowsers = supportedBrowsers.filter(
|
||||
(browser) => getMappedBrowser(browser) === "wayfern",
|
||||
);
|
||||
|
||||
const loadDetectedProfiles = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -176,7 +179,7 @@ export function ImportProfileDialog({
|
||||
|
||||
const mappedBrowser =
|
||||
importMode === "auto-detect" && selectedProfile
|
||||
? (selectedProfile.mapped_browser as "camoufox" | "wayfern")
|
||||
? getMappedBrowser(selectedProfile.mapped_browser)
|
||||
: getMappedBrowser(browserType);
|
||||
|
||||
setIsImporting(true);
|
||||
@@ -186,7 +189,8 @@ export function ImportProfileDialog({
|
||||
browserType,
|
||||
newProfileName,
|
||||
proxyId: selectedProxyId ?? null,
|
||||
camoufoxConfig: mappedBrowser === "camoufox" ? camoufoxConfig : null,
|
||||
// Camoufox import is deprecated/blocked; only Wayfern configs are sent.
|
||||
camoufoxConfig: null,
|
||||
wayfernConfig: mappedBrowser === "wayfern" ? wayfernConfig : null,
|
||||
});
|
||||
|
||||
@@ -199,7 +203,10 @@ export function ImportProfileDialog({
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (errorMessage.includes("No downloaded versions found")) {
|
||||
if (parseBackendError(error)) {
|
||||
// Structured backend error (e.g. CAMOUFOX_IMPORT_DEPRECATED) — localize.
|
||||
toast.error(translateBackendError(t, error));
|
||||
} else if (errorMessage.includes("No downloaded versions found")) {
|
||||
const browserDisplayName = getBrowserDisplayName(browserType);
|
||||
toast.error(
|
||||
t("importProfile.notInstalled", { browser: browserDisplayName }),
|
||||
@@ -222,7 +229,6 @@ export function ImportProfileDialog({
|
||||
manualProfilePath,
|
||||
manualProfileName,
|
||||
selectedProxyId,
|
||||
camoufoxConfig,
|
||||
wayfernConfig,
|
||||
onClose,
|
||||
selectedProfile,
|
||||
@@ -231,7 +237,6 @@ export function ImportProfileDialog({
|
||||
|
||||
const handleClose = () => {
|
||||
setCurrentStep("select");
|
||||
setCamoufoxConfig({});
|
||||
setWayfernConfig({});
|
||||
setSelectedProxyId(undefined);
|
||||
setSelectedDetectedProfile(null);
|
||||
@@ -262,10 +267,10 @@ export function ImportProfileDialog({
|
||||
|
||||
const currentMappedBrowser = useMemo(() => {
|
||||
if (importMode === "auto-detect" && selectedProfile) {
|
||||
return selectedProfile.mapped_browser as "camoufox" | "wayfern";
|
||||
return getMappedBrowser(selectedProfile.mapped_browser);
|
||||
}
|
||||
if (importMode === "manual" && manualBrowserType) {
|
||||
return manualBrowserType as "camoufox" | "wayfern";
|
||||
return getMappedBrowser(manualBrowserType);
|
||||
}
|
||||
return null;
|
||||
}, [importMode, selectedProfile, manualBrowserType]);
|
||||
@@ -301,14 +306,19 @@ export function ImportProfileDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogContent className="flex max-h-[80vh] max-w-[min(48rem,calc(100%-4rem))] flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("importProfile.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 space-y-6 overflow-y-auto",
|
||||
subPage && "mx-auto w-full max-w-2xl",
|
||||
)}
|
||||
>
|
||||
{currentStep === "select" && (
|
||||
<AnimatedTabs
|
||||
value={importMode}
|
||||
@@ -379,7 +389,7 @@ export function ImportProfileDialog({
|
||||
key={profile.path}
|
||||
value={profile.path}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
{IconComponent && (
|
||||
<IconComponent className="size-4" />
|
||||
)}
|
||||
@@ -403,8 +413,8 @@ export function ImportProfileDialog({
|
||||
</div>
|
||||
|
||||
{selectedProfile && (
|
||||
<div className="p-3 rounded-lg bg-muted">
|
||||
<p className="text-sm">
|
||||
<div className="rounded-lg bg-muted p-3">
|
||||
<p className="text-sm break-all">
|
||||
<span className="font-medium">
|
||||
{t("importProfile.pathLabel")}
|
||||
</span>{" "}
|
||||
@@ -471,7 +481,7 @@ export function ImportProfileDialog({
|
||||
const IconComponent = getBrowserIcon(browser);
|
||||
return (
|
||||
<SelectItem key={browser} value={browser}>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
{IconComponent && (
|
||||
<IconComponent className="size-4" />
|
||||
)}
|
||||
@@ -508,7 +518,7 @@ export function ImportProfileDialog({
|
||||
<FaFolder className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<p className="mt-2 text-xs break-all text-muted-foreground">
|
||||
{t("importProfile.examplePaths")}
|
||||
<br />
|
||||
macOS: ~/Library/Application
|
||||
@@ -577,35 +587,27 @@ export function ImportProfileDialog({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{currentMappedBrowser === "camoufox" ? (
|
||||
<SharedCamoufoxConfigForm
|
||||
config={camoufoxConfig}
|
||||
onConfigChange={(key, value) => {
|
||||
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
|
||||
}}
|
||||
isCreating={true}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
) : (
|
||||
<WayfernConfigForm
|
||||
config={wayfernConfig}
|
||||
onConfigChange={(key, value) => {
|
||||
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
||||
}}
|
||||
isCreating={true}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
)}
|
||||
{/* Only Wayfern profiles are importable now (Camoufox/Firefox
|
||||
import is deprecated and blocked). */}
|
||||
<WayfernConfigForm
|
||||
config={wayfernConfig}
|
||||
onConfigChange={(key, value) => {
|
||||
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
||||
}}
|
||||
isCreating={true}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 flex gap-2 items-center justify-end",
|
||||
subPage ? "pt-2 border-t border-border" : undefined,
|
||||
"flex shrink-0 items-center justify-end gap-2",
|
||||
subPage
|
||||
? "mx-auto w-full max-w-2xl border-t border-border pt-2"
|
||||
: undefined,
|
||||
)}
|
||||
>
|
||||
{currentStep === "select" ? (
|
||||
|
||||
@@ -32,6 +32,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CopyToClipboard } from "./ui/copy-to-clipboard";
|
||||
|
||||
interface AppSettings {
|
||||
@@ -307,14 +308,19 @@ export function IntegrationsDialog({
|
||||
}}
|
||||
subPage={subPage}
|
||||
>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] my-8 flex flex-col">
|
||||
<DialogContent className="flex max-h-[calc(100vh-5rem)] max-w-3xl flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("integrations.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto flex-1 min-h-0">
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto",
|
||||
subPage && "mx-auto w-full max-w-3xl",
|
||||
)}
|
||||
>
|
||||
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="api">
|
||||
@@ -327,12 +333,12 @@ export function IntegrationsDialog({
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="api"
|
||||
className="mt-4 flex flex-col gap-4"
|
||||
className="@container mt-4 flex flex-col gap-4"
|
||||
>
|
||||
<div className="rounded-md border bg-card p-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-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" />
|
||||
<LuPlug className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.apiEnableLabel")}
|
||||
@@ -364,9 +370,9 @@ export function IntegrationsDialog({
|
||||
|
||||
{settings.api_enabled && (
|
||||
<>
|
||||
<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">
|
||||
<div className="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiPortLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -457,9 +463,9 @@ export function IntegrationsDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiTokenLabel")}
|
||||
</Label>
|
||||
</div>
|
||||
@@ -469,13 +475,13 @@ export function IntegrationsDialog({
|
||||
type={showApiToken ? "text" : "password"}
|
||||
value={settings.api_token ?? ""}
|
||||
readOnly
|
||||
className="font-mono pr-10"
|
||||
className="pr-10 font-mono"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
setShowApiToken(!showApiToken);
|
||||
}}
|
||||
@@ -495,9 +501,9 @@ export function IntegrationsDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiExampleRequest")}
|
||||
</Label>
|
||||
<CopyToClipboard
|
||||
@@ -505,7 +511,7 @@ export function IntegrationsDialog({
|
||||
successMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</div>
|
||||
<pre className="font-mono text-[11px] whitespace-pre overflow-x-auto bg-background rounded p-3">
|
||||
<pre className="overflow-x-auto rounded bg-background p-3 font-mono text-[11px] whitespace-pre">
|
||||
{`curl -H "Authorization: Bearer \${TOKEN}" \\
|
||||
http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
|
||||
</pre>
|
||||
@@ -518,10 +524,10 @@ export function IntegrationsDialog({
|
||||
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 flex-col gap-4 rounded-md border bg-card p-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" />
|
||||
<LuZap className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.mcpEnableLabel")}
|
||||
@@ -546,8 +552,8 @@ export function IntegrationsDialog({
|
||||
|
||||
{mcpConfig && (
|
||||
<>
|
||||
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
|
||||
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.mcp.url")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-x-2">
|
||||
@@ -556,13 +562,13 @@ export function IntegrationsDialog({
|
||||
type={showMcpUrl ? "text" : "password"}
|
||||
value={mcpUrl}
|
||||
readOnly
|
||||
className="font-mono text-xs pr-10"
|
||||
className="pr-10 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
setShowMcpUrl(!showMcpUrl);
|
||||
}}
|
||||
@@ -581,32 +587,32 @@ export function IntegrationsDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="@container flex flex-col gap-3">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.mcp.clientsLabel")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 @2xl:grid-cols-2">
|
||||
{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"
|
||||
className="flex items-center gap-3 rounded-md border bg-card px-3 py-2.5"
|
||||
>
|
||||
<div className="grid place-items-center size-8 rounded-md bg-muted shrink-0">
|
||||
<div className="grid size-8 shrink-0 place-items-center rounded-md bg-muted">
|
||||
<AgentIcon category={agent.category} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{agent.display_name}
|
||||
</p>
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{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">
|
||||
<span className="inline-flex items-center gap-1 rounded-md border bg-muted px-2 py-1 text-[10px] font-medium tracking-wide text-foreground uppercase">
|
||||
<LuCheck className="size-3" />
|
||||
{t("integrations.mcp.connected")}
|
||||
</span>
|
||||
|
||||
@@ -233,7 +233,7 @@ export function LocationProxyDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="max-h-[calc(100vh-16rem)] min-h-0 space-y-4 overflow-y-auto pr-1">
|
||||
{/* Country - always visible */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
|
||||
@@ -152,7 +152,7 @@ const CommandEmpty = forwardRef<
|
||||
return (
|
||||
<div
|
||||
ref={forwardedRef}
|
||||
className={cn("py-6 text-sm text-center", className)}
|
||||
className={cn("py-6 text-center text-sm", className)}
|
||||
cmdk-empty=""
|
||||
role="presentation"
|
||||
{...props}
|
||||
@@ -194,8 +194,16 @@ const MultipleSelector = React.forwardRef<
|
||||
) => {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [dropUp, setDropUp] = React.useState(false);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
const updateDropUp = React.useCallback(() => {
|
||||
const rect = inputRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
setDropUp(spaceBelow < 240 && rect.top > spaceBelow);
|
||||
}, []);
|
||||
|
||||
const [selected, setSelected] = React.useState<Option[]>(value ?? []);
|
||||
const [options, setOptions] = React.useState<GroupOption>(
|
||||
transToGroupOption(arrayDefaultOptions, groupBy),
|
||||
@@ -203,6 +211,19 @@ const MultipleSelector = React.forwardRef<
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
const debouncedSearchTerm = useDebounce(inputValue, delay ?? 500);
|
||||
|
||||
// Re-evaluate the flip while the list is open: selecting options grows
|
||||
// the badge row (moving the input down) and window resizes change the
|
||||
// space below — both can invalidate the side chosen on focus.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
void selected.length;
|
||||
updateDropUp();
|
||||
window.addEventListener("resize", updateDropUp);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateDropUp);
|
||||
};
|
||||
}, [open, selected.length, updateDropUp]);
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -377,7 +398,7 @@ const MultipleSelector = React.forwardRef<
|
||||
commandProps?.onKeyDown?.(e);
|
||||
}}
|
||||
className={cn(
|
||||
"h-auto overflow-visible bg-transparent",
|
||||
"relative h-auto overflow-visible bg-transparent",
|
||||
commandProps?.className,
|
||||
)}
|
||||
shouldFilter={
|
||||
@@ -407,8 +428,8 @@ const MultipleSelector = React.forwardRef<
|
||||
<Badge
|
||||
key={option.value}
|
||||
className={cn(
|
||||
"data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground",
|
||||
"data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground",
|
||||
"data-disabled:bg-muted-foreground data-disabled:text-muted data-disabled:hover:bg-muted-foreground",
|
||||
"data-fixed:bg-muted-foreground data-fixed:text-muted data-fixed:hover:bg-muted-foreground",
|
||||
badgeClassName,
|
||||
)}
|
||||
data-fixed={option.fixed}
|
||||
@@ -418,7 +439,7 @@ const MultipleSelector = React.forwardRef<
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-pointer ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"ml-1 cursor-pointer rounded-full ring-offset-background outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
(disabled ?? option.fixed) && "hidden",
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -488,6 +509,7 @@ const MultipleSelector = React.forwardRef<
|
||||
inputProps?.onBlur?.(event);
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
updateDropUp();
|
||||
setOpen(true);
|
||||
if (triggerSearchOnFocus && onSearch) {
|
||||
void onSearch(debouncedSearchTerm);
|
||||
@@ -503,7 +525,7 @@ const MultipleSelector = React.forwardRef<
|
||||
"flex-1 bg-transparent outline-none placeholder:text-muted-foreground",
|
||||
{
|
||||
"w-full": hidePlaceholderWhenSelected,
|
||||
"px-3 mt-1": selected.length === 0,
|
||||
"mt-1 px-3": selected.length === 0,
|
||||
"ml-1": selected.length !== 0,
|
||||
},
|
||||
inputProps?.className,
|
||||
@@ -511,9 +533,14 @@ const MultipleSelector = React.forwardRef<
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div>
|
||||
{open && hasAvailableOptions && (
|
||||
<CommandList className="absolute top-1 z-10 w-full rounded-md border shadow-md outline-none bg-popover text-popover-foreground animate-in">
|
||||
<CommandList
|
||||
className={cn(
|
||||
"absolute z-10 w-full animate-in rounded-md border bg-popover text-popover-foreground shadow-md outline-none",
|
||||
dropUp ? "bottom-full mb-1" : "top-full mt-1",
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
loadingIndicator
|
||||
) : (
|
||||
@@ -527,7 +554,7 @@ const MultipleSelector = React.forwardRef<
|
||||
<CommandGroup
|
||||
key={key}
|
||||
heading={key}
|
||||
className="overflow-auto h-24"
|
||||
className="max-h-48 overflow-auto"
|
||||
>
|
||||
{dropdowns.map((option) => {
|
||||
return (
|
||||
|
||||
@@ -28,26 +28,26 @@ export function OnboardingCard({
|
||||
const requiresAction = step.selector === '[data-onborda="create-profile"]';
|
||||
|
||||
return (
|
||||
<div className="relative p-4 w-80 max-w-[90vw] rounded-lg border shadow-lg bg-popover text-popover-foreground">
|
||||
<div className="flex gap-2 items-start justify-between">
|
||||
<h3 className="text-sm font-semibold leading-tight">{step.title}</h3>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||
<div className="relative w-80 max-w-[90vw] rounded-lg border bg-popover p-4 text-popover-foreground shadow-lg">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-sm/tight font-semibold">{step.title}</h3>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground tabular-nums">
|
||||
{currentStep + 1}/{totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs leading-relaxed text-muted-foreground">
|
||||
<div className="mt-2 text-xs/relaxed text-muted-foreground">
|
||||
{step.content}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center justify-between mt-4">
|
||||
<div className="mt-4 flex items-center justify-between gap-2">
|
||||
{isLast ? (
|
||||
<span />
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
closeOnborda();
|
||||
}}
|
||||
@@ -56,12 +56,12 @@ export function OnboardingCard({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
{!isFirst && !isLast && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 px-2.5"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={() => {
|
||||
prevStep();
|
||||
}}
|
||||
@@ -72,7 +72,7 @@ export function OnboardingCard({
|
||||
{isLast ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-xs h-7 px-3"
|
||||
className="h-7 px-3 text-xs"
|
||||
onClick={() => {
|
||||
closeOnborda();
|
||||
window.dispatchEvent(new Event(ONBOARDING_TOUR_FINISHED_EVENT));
|
||||
@@ -83,7 +83,7 @@ export function OnboardingCard({
|
||||
) : requiresAction ? null : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-xs h-7 px-3"
|
||||
className="h-7 px-3 text-xs"
|
||||
onClick={() => {
|
||||
nextStep();
|
||||
}}
|
||||
|
||||
@@ -206,7 +206,7 @@ export function PermissionDialog({
|
||||
|
||||
<div className="space-y-4">
|
||||
{!isCurrentPermissionGranted && (
|
||||
<div className="p-3 bg-warning/10 rounded-lg">
|
||||
<div className="rounded-lg bg-warning/10 p-3">
|
||||
<p className="text-sm text-warning">
|
||||
{permissionType === "microphone"
|
||||
? t("permissionDialog.notGrantedMicrophone")
|
||||
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
type RowData,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
@@ -49,11 +51,18 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -81,7 +90,6 @@ import {
|
||||
isCrossOsProfile,
|
||||
} from "@/lib/browser-utils";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import { trimName } from "@/lib/name-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
BrowserProfile,
|
||||
@@ -105,6 +113,15 @@ import { TrafficDetailsDialog } from "./traffic-details-dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
// Emit no width for this column so table-fixed hands it all remaining
|
||||
// space. Checking columnDef.size alone can't express this: TanStack
|
||||
// resolves an unspecified size to its 150px default.
|
||||
flexWidth?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
// Stable table meta type to pass volatile state/handlers into TanStack Table without
|
||||
// causing column definitions to be recreated on every render.
|
||||
interface TableMeta {
|
||||
@@ -348,10 +365,10 @@ function ExtCell({
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
className="flex h-7 w-full items-center gap-1.5 rounded px-1.5 text-left text-xs text-muted-foreground transition-colors duration-100 hover:bg-accent/50 hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
<LuPuzzle className="size-3 shrink-0" />
|
||||
<span className="truncate flex-1" title={label}>
|
||||
<span className="flex-1 truncate" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
<LuChevronDown className="size-3 shrink-0 text-muted-foreground" />
|
||||
@@ -443,7 +460,7 @@ function DnsCell({
|
||||
type="button"
|
||||
data-onborda="dns-blocklist"
|
||||
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"
|
||||
className="flex h-7 w-full items-center gap-1.5 rounded px-1.5 text-left text-xs text-muted-foreground transition-colors duration-100 hover:bg-accent/50 hover:text-foreground disabled:opacity-50"
|
||||
title={
|
||||
level
|
||||
? meta.t("profiles.table.dnsLevel", { level })
|
||||
@@ -664,9 +681,9 @@ const TagsCell = React.memo<{
|
||||
type="button"
|
||||
ref={containerRef as unknown as React.RefObject<HTMLButtonElement>}
|
||||
className={cn(
|
||||
"flex overflow-hidden gap-1 items-center px-2 py-1 h-6 w-full bg-transparent rounded border-none cursor-pointer",
|
||||
"flex h-6 w-full cursor-pointer items-center gap-1 overflow-hidden rounded border-none bg-transparent px-2 py-1",
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
? "cursor-not-allowed opacity-60"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
@@ -692,7 +709,7 @@ const TagsCell = React.memo<{
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-6 cursor-pointer">
|
||||
<div className="h-6 w-full cursor-pointer">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{ButtonContent}</TooltipTrigger>
|
||||
{hiddenCount > 0 && (
|
||||
@@ -718,13 +735,13 @@ const TagsCell = React.memo<{
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-6 relative",
|
||||
isDisabled && "opacity-60 pointer-events-none",
|
||||
"relative h-6 w-full",
|
||||
isDisabled && "pointer-events-none opacity-60",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={editorRef}
|
||||
className="absolute top-0 left-0 z-50 w-40 min-h-6 bg-popover rounded-md shadow-md"
|
||||
className="absolute top-0 left-0 z-50 min-h-6 w-40 rounded-md bg-popover shadow-md"
|
||||
>
|
||||
<MultipleSelector
|
||||
value={valueOptions}
|
||||
@@ -738,11 +755,11 @@ const TagsCell = React.memo<{
|
||||
: ""
|
||||
}
|
||||
className={cn(
|
||||
"bg-transparent border-0! focus-within:ring-0!",
|
||||
"border-0! bg-transparent focus-within:ring-0!",
|
||||
"[&_div:first-child]:border-0! [&_div:first-child]:ring-0! [&_div:first-child]:focus-within:ring-0!",
|
||||
"[&_div:first-child]:min-h-6! [&_div:first-child]:px-2! [&_div:first-child]:py-1!",
|
||||
"[&_div:first-child>div]:items-center [&_div:first-child>div]:h-6!",
|
||||
"[&_input]:ml-0! [&_input]:mt-0! [&_input]:px-0!",
|
||||
"[&_div:first-child>div]:h-6! [&_div:first-child>div]:items-center",
|
||||
"[&_input]:mt-0! [&_input]:ml-0! [&_input]:px-0!",
|
||||
!isFocused && "[&_div:first-child>div]:justify-center",
|
||||
)}
|
||||
badgeClassName="shrink-0"
|
||||
@@ -822,6 +839,96 @@ const NonHoverableTooltip = React.memo<{
|
||||
|
||||
NonHoverableTooltip.displayName = "NonHoverableTooltip";
|
||||
|
||||
// CSS-truncated text whose tooltip only appears when the text actually
|
||||
// overflows its column (measured on hover, so it tracks live resizes).
|
||||
const OverflowTooltipText = React.memo<{
|
||||
text: string;
|
||||
className?: string;
|
||||
}>(({ text, className }) => {
|
||||
const textRef = React.useRef<HTMLSpanElement | null>(null);
|
||||
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
onOpenChange={(open) => {
|
||||
if (!open) return;
|
||||
const el = textRef.current;
|
||||
if (el) setIsOverflowing(el.scrollWidth > el.clientWidth);
|
||||
}}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
ref={textRef}
|
||||
className={cn("block max-w-full min-w-0 truncate", className)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{isOverflowing && <TooltipContent>{text}</TooltipContent>}
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
OverflowTooltipText.displayName = "OverflowTooltipText";
|
||||
|
||||
// Must be rendered inside a <Popover>; the tooltip shows the full assignment
|
||||
// name only when it is truncated in the cell.
|
||||
const ProxyCellTrigger = React.memo<{
|
||||
displayName: string;
|
||||
hasAssignment: boolean;
|
||||
vpnBadge: string | null;
|
||||
isDisabled: boolean;
|
||||
}>(({ displayName, hasAssignment, vpnBadge, isDisabled }) => {
|
||||
const textRef = React.useRef<HTMLSpanElement | null>(null);
|
||||
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
onOpenChange={(open) => {
|
||||
if (!open) return;
|
||||
const el = textRef.current;
|
||||
if (el) setIsOverflowing(el.scrollWidth > el.clientWidth);
|
||||
}}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"flex max-w-full min-w-0 items-center gap-2 rounded px-2 py-1",
|
||||
isDisabled
|
||||
? "pointer-events-none cursor-not-allowed opacity-60"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{vpnBadge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 px-1 py-0 text-[10px] leading-tight"
|
||||
>
|
||||
{vpnBadge}
|
||||
</Badge>
|
||||
)}
|
||||
<span
|
||||
ref={textRef}
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm",
|
||||
!hasAssignment && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
{hasAssignment && isOverflowing && (
|
||||
<TooltipContent>{displayName}</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
ProxyCellTrigger.displayName = "ProxyCellTrigger";
|
||||
|
||||
const NoteCell = React.memo<{
|
||||
profile: BrowserProfile;
|
||||
isDisabled: boolean;
|
||||
@@ -930,15 +1037,15 @@ const NoteCell = React.memo<{
|
||||
|
||||
if (openNoteEditorFor !== profile.id) {
|
||||
return (
|
||||
<div className="w-full min-h-6">
|
||||
<div className="min-h-6 w-full">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center px-2 py-1 min-h-6 w-full min-w-0 bg-transparent rounded border-none text-left",
|
||||
"flex min-h-6 w-full min-w-0 items-center rounded border-none bg-transparent px-2 py-1 text-left",
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
? "cursor-not-allowed opacity-60"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
@@ -950,7 +1057,7 @@ const NoteCell = React.memo<{
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm truncate block w-full",
|
||||
"block w-full truncate text-sm",
|
||||
!effectiveNote && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -960,7 +1067,7 @@ const NoteCell = React.memo<{
|
||||
</TooltipTrigger>
|
||||
{showTooltip && (
|
||||
<TooltipContent className="max-w-[320px]">
|
||||
<p className="whitespace-pre-wrap wrap-break-word">
|
||||
<p className="wrap-break-word whitespace-pre-wrap">
|
||||
{effectiveNote ?? t("profiles.note.empty")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
@@ -973,13 +1080,13 @@ const NoteCell = React.memo<{
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full relative",
|
||||
isDisabled && "opacity-60 pointer-events-none",
|
||||
"relative w-full",
|
||||
isDisabled && "pointer-events-none opacity-60",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={editorRef}
|
||||
className="absolute -top-[15px] -left-px z-50 w-60 min-h-6 bg-popover rounded-md shadow-md border"
|
||||
className="absolute top-[-15px] -left-px z-50 min-h-6 w-60 rounded-md border bg-popover shadow-md"
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -999,7 +1106,7 @@ const NoteCell = React.memo<{
|
||||
setOpenNoteEditorFor(null);
|
||||
}}
|
||||
placeholder={t("profiles.note.placeholder")}
|
||||
className="w-full min-h-6 max-h-[200px] px-2 py-1 text-sm bg-transparent border-0 resize-none focus:outline-none focus:ring-0"
|
||||
className="max-h-[200px] min-h-6 w-full resize-none border-0 bg-transparent px-2 py-1 text-sm focus:ring-0 focus:outline-none"
|
||||
style={{
|
||||
overflow: "auto",
|
||||
}}
|
||||
@@ -1034,6 +1141,9 @@ interface ProfilesDataTableProps {
|
||||
onBulkGroupAssignment?: () => void;
|
||||
onBulkProxyAssignment?: () => void;
|
||||
onBulkCopyCookies?: () => void;
|
||||
onBulkRun?: () => void;
|
||||
onBulkStop?: () => void;
|
||||
bulkActionsUnlocked?: boolean;
|
||||
onBulkExtensionGroupAssignment?: () => void;
|
||||
onAssignExtensionGroup?: (profileIds: string[]) => void;
|
||||
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
|
||||
@@ -1079,6 +1189,9 @@ export function ProfilesDataTable({
|
||||
onBulkGroupAssignment,
|
||||
onBulkProxyAssignment,
|
||||
onBulkCopyCookies,
|
||||
onBulkRun,
|
||||
onBulkStop,
|
||||
bulkActionsUnlocked = false,
|
||||
onBulkExtensionGroupAssignment,
|
||||
onAssignExtensionGroup,
|
||||
onOpenProfileSyncDialog,
|
||||
@@ -1137,14 +1250,16 @@ export function ProfilesDataTable({
|
||||
(id) => newSelection[id],
|
||||
);
|
||||
|
||||
// Only update external state if selection actually changed
|
||||
const prevIds = Object.keys(prevSelection).filter(
|
||||
(id) => prevSelection[id],
|
||||
// Only update external state if selection actually changed.
|
||||
// A Set gives O(1) membership; Array.includes() inside .every() would
|
||||
// be O(n*m) over large selections.
|
||||
const prevIdSet = new Set(
|
||||
Object.keys(prevSelection).filter((id) => prevSelection[id]),
|
||||
);
|
||||
|
||||
if (
|
||||
selectedIds.length !== prevIds.length ||
|
||||
!selectedIds.every((id) => prevIds.includes(id))
|
||||
selectedIds.length !== prevIdSet.size ||
|
||||
!selectedIds.every((id) => prevIdSet.has(id))
|
||||
) {
|
||||
onSelectedProfilesChange(selectedIds);
|
||||
}
|
||||
@@ -1446,10 +1561,13 @@ export function ProfilesDataTable({
|
||||
"get_all_traffic_snapshots",
|
||||
);
|
||||
const newSnapshots: Record<string, TrafficSnapshot> = {};
|
||||
// O(1) membership; runningProfileIds.includes() in this loop would be
|
||||
// O(snapshots * runningProfiles).
|
||||
const runningSet = new Set(runningProfileIds);
|
||||
for (const snapshot of allSnapshots) {
|
||||
if (snapshot.profile_id) {
|
||||
// Only keep snapshots for profiles that are currently running
|
||||
if (runningProfileIds.includes(snapshot.profile_id)) {
|
||||
if (runningSet.has(snapshot.profile_id)) {
|
||||
const existing = newSnapshots[snapshot.profile_id];
|
||||
if (!existing || snapshot.last_update > existing.last_update) {
|
||||
newSnapshots[snapshot.profile_id] = snapshot;
|
||||
@@ -1478,9 +1596,10 @@ export function ProfilesDataTable({
|
||||
|
||||
setTrafficSnapshots((prev) => {
|
||||
const cleaned: Record<string, TrafficSnapshot> = {};
|
||||
const runningSet = new Set(runningProfileIds);
|
||||
for (const [profileId, snapshot] of Object.entries(prev)) {
|
||||
// Only keep snapshots for profiles that are currently running
|
||||
if (runningProfileIds.includes(profileId)) {
|
||||
if (runningSet.has(profileId)) {
|
||||
cleaned[profileId] = snapshot;
|
||||
}
|
||||
}
|
||||
@@ -1984,18 +2103,18 @@ export function ProfilesDataTable({
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex justify-center items-center size-4">
|
||||
<span className="flex size-4 items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex justify-center items-center p-0 border-none cursor-pointer"
|
||||
className="flex cursor-pointer items-center justify-center border-none p-0"
|
||||
onClick={() => {
|
||||
meta.handleIconClick(profile.id);
|
||||
}}
|
||||
aria-label={t("common.aria.selectProfile")}
|
||||
>
|
||||
<span className="size-4 group">
|
||||
<span className="group size-4">
|
||||
<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 className="peer pointer-events-none hidden size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-xs transition-shadow duration-150 outline-none group-hover:block dark:bg-input/30 dark:data-[state=checked]:bg-primary" />
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
@@ -2023,7 +2142,7 @@ export function ProfilesDataTable({
|
||||
sideOffset={4}
|
||||
horizontalOffset={8}
|
||||
>
|
||||
<span className="flex justify-center items-center size-4">
|
||||
<span className="flex size-4 items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(value) => {
|
||||
@@ -2039,17 +2158,17 @@ export function ProfilesDataTable({
|
||||
|
||||
if (isDisabled) {
|
||||
const tooltipMessage = isRunning
|
||||
? "Can't modify running profile"
|
||||
? t("profiles.table.cantModifyRunning")
|
||||
: isLaunching
|
||||
? "Can't modify profile while launching"
|
||||
? t("profiles.table.cantModifyLaunching")
|
||||
: isStopping
|
||||
? "Can't modify profile while stopping"
|
||||
: "Can't modify profile while browser is updating";
|
||||
? t("profiles.table.cantModifyStopping")
|
||||
: t("profiles.table.cantModifyUpdating");
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex justify-center items-center size-4 cursor-not-allowed">
|
||||
<span className="flex size-4 cursor-not-allowed items-center justify-center">
|
||||
{IconComponent && (
|
||||
<IconComponent className="size-4 opacity-50" />
|
||||
)}
|
||||
@@ -2071,7 +2190,7 @@ export function ProfilesDataTable({
|
||||
sideOffset={4}
|
||||
horizontalOffset={8}
|
||||
>
|
||||
<span className="flex justify-center items-center size-4">
|
||||
<span className="flex size-4 items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(value) => {
|
||||
@@ -2091,20 +2210,20 @@ export function ProfilesDataTable({
|
||||
sideOffset={4}
|
||||
horizontalOffset={8}
|
||||
>
|
||||
<span className="flex relative justify-center items-center size-4">
|
||||
<span className="relative flex size-4 items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex justify-center items-center p-0 border-none cursor-pointer"
|
||||
className="flex cursor-pointer items-center justify-center border-none p-0"
|
||||
onClick={() => {
|
||||
meta.handleIconClick(profile.id);
|
||||
}}
|
||||
aria-label={t("common.aria.selectProfile")}
|
||||
>
|
||||
<span className="size-4 group">
|
||||
<span className="group size-4">
|
||||
{IconComponent && (
|
||||
<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 size-4 hidden group-hover:block pointer-events-none items-center justify-center duration-150" />
|
||||
<span className="peer pointer-events-none hidden size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-xs transition-shadow duration-150 outline-none group-hover:block dark:bg-input/30 dark:data-[state=checked]:bg-primary" />
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
@@ -2213,7 +2332,7 @@ export function ProfilesDataTable({
|
||||
: "default";
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
{isDesynced && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -2241,8 +2360,8 @@ export function ProfilesDataTable({
|
||||
: meta.t("profiles.actions.launch")
|
||||
}
|
||||
className={cn(
|
||||
"size-7 p-0 grid place-items-center",
|
||||
!canLaunch && "opacity-50 cursor-not-allowed",
|
||||
"grid size-7 place-items-center p-0",
|
||||
!canLaunch && "cursor-not-allowed opacity-50",
|
||||
canLaunch && "cursor-pointer",
|
||||
isFollower && "border-accent",
|
||||
isRunning &&
|
||||
@@ -2255,7 +2374,7 @@ export function ProfilesDataTable({
|
||||
}
|
||||
>
|
||||
{isLaunching || isStopping ? (
|
||||
<div className="size-3 rounded-full border border-current animate-spin border-t-transparent" />
|
||||
<div className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
|
||||
) : isRunning ? (
|
||||
<LuSquare className="size-3.5 fill-current" />
|
||||
) : (
|
||||
@@ -2274,26 +2393,89 @@ export function ProfilesDataTable({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Hidden, sort-only column so profiles can be sorted by creation date
|
||||
// without showing a Created column in the table (issue #454). Kept
|
||||
// hidden via columnVisibility; sorting still works on hidden columns.
|
||||
id: "created_at",
|
||||
accessorFn: (row) => row.created_at ?? 0,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
sortingFn: "basic",
|
||||
header: () => null,
|
||||
cell: () => null,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
size: 130,
|
||||
header: ({ column, table }) => {
|
||||
// The only column without a fixed width: table-fixed hands it all
|
||||
// remaining space as the window grows or shrinks.
|
||||
meta: { flexWidth: true },
|
||||
// The Name header doubles as the sort control: clicking opens a menu to
|
||||
// sort by name (A–Z / Z–A) or by creation date (newest / oldest), so
|
||||
// creation-date sorting needs no visible column.
|
||||
header: ({ table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const sort = table.getState().sorting[0];
|
||||
const isActive = (id: string, desc: boolean) =>
|
||||
sort?.id === id && !!sort.desc === desc;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
>
|
||||
{meta.t("common.labels.name")}
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
<LuChevronUp className="ml-2 size-4" />
|
||||
) : column.getIsSorted() === "desc" ? (
|
||||
<LuChevronDown className="ml-2 size-4" />
|
||||
) : null}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto cursor-pointer justify-start p-0 text-left font-semibold"
|
||||
>
|
||||
{meta.t("common.labels.name")}
|
||||
{isActive("name", false) ? (
|
||||
<LuChevronUp className="ml-2 size-4" />
|
||||
) : isActive("name", true) ? (
|
||||
<LuChevronDown className="ml-2 size-4" />
|
||||
) : (
|
||||
<LuChevronDown className="ml-2 size-4 opacity-50" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
table.setSorting([{ id: "name", desc: false }])
|
||||
}
|
||||
>
|
||||
{isActive("name", false) && (
|
||||
<LuCheck className="mr-2 size-3.5" />
|
||||
)}
|
||||
{meta.t("profiles.sort.nameAsc")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => table.setSorting([{ id: "name", desc: true }])}
|
||||
>
|
||||
{isActive("name", true) && (
|
||||
<LuCheck className="mr-2 size-3.5" />
|
||||
)}
|
||||
{meta.t("profiles.sort.nameDesc")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
table.setSorting([{ id: "created_at", desc: true }])
|
||||
}
|
||||
>
|
||||
{isActive("created_at", true) && (
|
||||
<LuCheck className="mr-2 size-3.5" />
|
||||
)}
|
||||
{meta.t("profiles.sort.newest")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
table.setSorting([{ id: "created_at", desc: false }])
|
||||
}
|
||||
>
|
||||
{isActive("created_at", false) && (
|
||||
<LuCheck className="mr-2 size-3.5" />
|
||||
)}
|
||||
{meta.t("profiles.sort.oldest")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
enableSorting: true,
|
||||
@@ -2309,7 +2491,7 @@ export function ProfilesDataTable({
|
||||
return (
|
||||
<div
|
||||
ref={renameContainerRef}
|
||||
className="overflow-visible relative"
|
||||
className="relative overflow-visible"
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
@@ -2341,27 +2523,18 @@ export function ProfilesDataTable({
|
||||
meta.setRenameError(null);
|
||||
}
|
||||
}}
|
||||
className="w-30 h-6 px-2 py-1 text-sm font-medium leading-none border-0 shadow-none focus-visible:ring-0"
|
||||
className="h-6 w-full max-w-full min-w-0 border-0 px-2 py-1 text-sm leading-none font-medium shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const display =
|
||||
name.length < 14 ? (
|
||||
<div className="font-medium text-left leading-none truncate">
|
||||
{name}
|
||||
</div>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="leading-none block truncate">
|
||||
{trimName(name, 14)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{name}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
const display = (
|
||||
<OverflowTooltipText
|
||||
text={name}
|
||||
className="text-left leading-none font-medium"
|
||||
/>
|
||||
);
|
||||
|
||||
const isCrossOs = isCrossOsProfile(profile);
|
||||
const isCrossOsBlocked = isCrossOs;
|
||||
@@ -2375,13 +2548,13 @@ export function ProfilesDataTable({
|
||||
const isLocked = meta.isProfileLockedByAnother(profile.id);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0 max-w-full overflow-hidden">
|
||||
<div className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-2 py-1 mr-auto text-left bg-transparent rounded border-none h-6 min-w-0 max-w-full overflow-hidden",
|
||||
"mr-auto h-6 max-w-full min-w-0 overflow-hidden rounded border-none bg-transparent px-2 py-1 text-left",
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
? "cursor-not-allowed opacity-60"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
@@ -2528,7 +2701,6 @@ export function ProfilesDataTable({
|
||||
? effectiveProxy.name
|
||||
: meta.t("profiles.table.notSelected");
|
||||
const vpnBadge = effectiveVpn ? "WG" : null;
|
||||
const tooltipText = hasAssignment ? displayName : null;
|
||||
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
|
||||
const selectedId = effectiveVpnId ?? effectiveProxyId ?? null;
|
||||
|
||||
@@ -2543,7 +2715,7 @@ export function ProfilesDataTable({
|
||||
(snapshot?.current_bytes_received ?? 0);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden min-w-0">
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<BandwidthMiniChart
|
||||
key={`${profile.id}-${snapshot?.last_update ?? 0}-${bandwidthData.length}`}
|
||||
data={bandwidthData}
|
||||
@@ -2555,49 +2727,19 @@ export function ProfilesDataTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex overflow-hidden gap-2 items-center min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
|
||||
<Popover
|
||||
open={isSelectorOpen}
|
||||
onOpenChange={(open) => {
|
||||
meta.setOpenProxySelectorFor(open ? profile.id : null);
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"flex gap-2 items-center px-2 py-1 rounded",
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed pointer-events-none"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{vpnBadge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight"
|
||||
>
|
||||
{vpnBadge}
|
||||
</Badge>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
!hasAssignment && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{hasAssignment
|
||||
? trimName(displayName, 10)
|
||||
: displayName}
|
||||
</span>
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
{tooltipText && (
|
||||
<TooltipContent>{tooltipText}</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<ProxyCellTrigger
|
||||
displayName={displayName}
|
||||
hasAssignment={hasAssignment}
|
||||
vpnBadge={vpnBadge}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
|
||||
{!isDisabled && (
|
||||
<PopoverContent
|
||||
@@ -2691,7 +2833,7 @@ export function ProfilesDataTable({
|
||||
/>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
className="mr-1 px-1 py-0 text-[10px] leading-tight"
|
||||
>
|
||||
WG
|
||||
</Badge>
|
||||
@@ -2814,7 +2956,7 @@ export function ProfilesDataTable({
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex justify-center items-center h-9 w-full">
|
||||
<span className="flex h-9 w-full items-center justify-center">
|
||||
{dot.encrypted ? (
|
||||
<LuLock
|
||||
className={`size-3 ${dot.color.replace("bg-", "text-")}${dot.animate ? " animate-pulse" : ""}`}
|
||||
@@ -2839,10 +2981,10 @@ export function ProfilesDataTable({
|
||||
const profile = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex justify-end items-center h-9 w-full">
|
||||
<div className="flex h-9 w-full items-center justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-0 size-7"
|
||||
className="size-7 p-0"
|
||||
disabled={!meta.isClient}
|
||||
onClick={() => {
|
||||
setProfileForInfoDialog(profile);
|
||||
@@ -2861,15 +3003,29 @@ export function ProfilesDataTable({
|
||||
[t, setProfileForInfoDialog],
|
||||
);
|
||||
|
||||
// Low-priority columns leave the table as the container narrows (most
|
||||
// expendable first); their data stays reachable via the profile info
|
||||
// dialog. Visibility (not CSS hiding) so table-fixed reclaims the width.
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({ created_at: false });
|
||||
|
||||
// Content columns grow proportionally with the container but never drop
|
||||
// below the compact-layout floor; the name column takes the remainder.
|
||||
// Computed in px from the observed container width because fixed table
|
||||
// layout ignores max()/calc() column widths.
|
||||
const [containerWidth, setContainerWidth] = React.useState(0);
|
||||
|
||||
const table = useReactTable({
|
||||
data: profiles,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection,
|
||||
columnVisibility,
|
||||
},
|
||||
onSortingChange: handleSortingChange,
|
||||
onRowSelectionChange: handleRowSelectionChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
enableRowSelection: (row) => {
|
||||
const profile = row.original;
|
||||
const isRunning =
|
||||
@@ -2885,9 +3041,52 @@ export function ProfilesDataTable({
|
||||
});
|
||||
|
||||
const scrollParentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const columnWidth = React.useCallback(
|
||||
(id: string, sizePx: number) => {
|
||||
const proportions: Record<string, { pct: number; floor: number }> = {
|
||||
tags: { pct: 0.12, floor: 100 },
|
||||
note: { pct: 0.1, floor: 80 },
|
||||
proxy: { pct: 0.13, floor: 110 },
|
||||
ext: { pct: 0.11, floor: 95 },
|
||||
dns: { pct: 0.11, floor: 95 },
|
||||
};
|
||||
const p = proportions[id];
|
||||
if (!p) return `${sizePx}px`;
|
||||
return `${Math.max(p.floor, Math.round(containerWidth * p.pct))}px`;
|
||||
},
|
||||
[containerWidth],
|
||||
);
|
||||
const sortedRows = table.getRowModel().rows;
|
||||
useScrollFade(scrollParentRef);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = scrollParentRef.current;
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
const w = el.clientWidth;
|
||||
setContainerWidth(Math.round(w / 8) * 8);
|
||||
setColumnVisibility((prev) => {
|
||||
const next: VisibilityState = {
|
||||
// Always hidden — sort-only column (issue #454).
|
||||
created_at: false,
|
||||
dns: w >= 768,
|
||||
ext: w >= 672,
|
||||
note: w >= 576,
|
||||
tags: w >= 512,
|
||||
};
|
||||
return Object.keys(next).every((k) => prev[k] === next[k])
|
||||
? prev
|
||||
: next;
|
||||
});
|
||||
};
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Compact 36px row from the redesign spec; estimateSize must match the
|
||||
// actual rendered row height or virtualizer placement drifts under scroll.
|
||||
const ROW_HEIGHT = 36;
|
||||
@@ -2909,10 +3108,16 @@ export function ProfilesDataTable({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
ref={scrollParentRef}
|
||||
className="overflow-auto relative flex-1 min-h-0 scroll-fade"
|
||||
className={cn(
|
||||
"scroll-fade relative min-h-0 flex-1 overflow-auto",
|
||||
// Clearance for the floating selection action bar (bottom-6 +
|
||||
// ~46px tall) so the last rows can scroll out from behind it.
|
||||
// Same predicate DataTableActionBar uses for its visibility.
|
||||
table.getFilteredSelectedRowModel().rows.length > 0 && "pb-20",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
// Sticky table header is 32px tall (h-8); shift the top
|
||||
@@ -2922,21 +3127,24 @@ export function ProfilesDataTable({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="overflow-visible sticky top-0 z-10 bg-background [&_tr]:border-0">
|
||||
<Table className="table-fixed" containerClassName="overflow-visible">
|
||||
<TableHeader className="sticky top-0 z-10 overflow-visible bg-background [&_tr]:border-0">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow
|
||||
key={headerGroup.id}
|
||||
className="overflow-visible !border-0"
|
||||
className="overflow-visible border-0!"
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width: header.column.columnDef.size
|
||||
? `${header.column.getSize()}px`
|
||||
: undefined,
|
||||
width: header.column.columnDef.meta?.flexWidth
|
||||
? undefined
|
||||
: columnWidth(
|
||||
header.column.id,
|
||||
header.column.getSize(),
|
||||
),
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
@@ -2955,7 +3163,7 @@ export function ProfilesDataTable({
|
||||
{sortedRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
colSpan={table.getVisibleLeafColumns().length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("profiles.table.empty")}
|
||||
@@ -2965,7 +3173,7 @@ export function ProfilesDataTable({
|
||||
<>
|
||||
{paddingTop > 0 && (
|
||||
<tr style={{ height: `${paddingTop}px` }}>
|
||||
<td colSpan={columns.length} />
|
||||
<td colSpan={table.getVisibleLeafColumns().length} />
|
||||
</tr>
|
||||
)}
|
||||
{virtualRows.map((virtualRow) => {
|
||||
@@ -2988,7 +3196,7 @@ export function ProfilesDataTable({
|
||||
title={crossOsTitle}
|
||||
style={{ height: `${ROW_HEIGHT}px` }}
|
||||
className={cn(
|
||||
"overflow-visible hover:bg-accent/50 !border-0",
|
||||
"overflow-visible border-0! hover:bg-accent/50",
|
||||
rowIsCrossOs && "opacity-60",
|
||||
)}
|
||||
>
|
||||
@@ -2997,9 +3205,12 @@ export function ProfilesDataTable({
|
||||
key={cell.id}
|
||||
className="overflow-visible py-0"
|
||||
style={{
|
||||
width: cell.column.columnDef.size
|
||||
? `${cell.column.getSize()}px`
|
||||
: undefined,
|
||||
width: cell.column.columnDef.meta?.flexWidth
|
||||
? undefined
|
||||
: columnWidth(
|
||||
cell.column.id,
|
||||
cell.column.getSize(),
|
||||
),
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
@@ -3013,7 +3224,7 @@ export function ProfilesDataTable({
|
||||
})}
|
||||
{paddingBottom > 0 && (
|
||||
<tr style={{ height: `${paddingBottom}px` }}>
|
||||
<td colSpan={columns.length} />
|
||||
<td colSpan={table.getVisibleLeafColumns().length} />
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
@@ -3094,6 +3305,44 @@ export function ProfilesDataTable({
|
||||
})()}
|
||||
<DataTableActionBar table={table}>
|
||||
<DataTableActionBarSelection table={table} />
|
||||
{onBulkRun && (
|
||||
<span className="relative inline-flex">
|
||||
<DataTableActionBarAction
|
||||
tooltip={
|
||||
bulkActionsUnlocked
|
||||
? t("profiles.actionBar.runSelected")
|
||||
: t("profiles.actionBar.proRequired")
|
||||
}
|
||||
onClick={bulkActionsUnlocked ? onBulkRun : undefined}
|
||||
disabled={!bulkActionsUnlocked}
|
||||
size="icon"
|
||||
>
|
||||
<LuPlay className="fill-current" />
|
||||
</DataTableActionBarAction>
|
||||
{!bulkActionsUnlocked && (
|
||||
<ProBadge className="pointer-events-none absolute -top-2 -right-2" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{onBulkStop && (
|
||||
<span className="relative inline-flex">
|
||||
<DataTableActionBarAction
|
||||
tooltip={
|
||||
bulkActionsUnlocked
|
||||
? t("profiles.actionBar.stopSelected")
|
||||
: t("profiles.actionBar.proRequired")
|
||||
}
|
||||
onClick={bulkActionsUnlocked ? onBulkStop : undefined}
|
||||
disabled={!bulkActionsUnlocked}
|
||||
size="icon"
|
||||
>
|
||||
<LuSquare className="fill-current" />
|
||||
</DataTableActionBarAction>
|
||||
{!bulkActionsUnlocked && (
|
||||
<ProBadge className="pointer-events-none absolute -top-2 -right-2" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{onBulkGroupAssignment && (
|
||||
<DataTableActionBarAction
|
||||
tooltip={t("profiles.actionBar.assignToGroup")}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
LuClipboardCheck,
|
||||
LuCookie,
|
||||
LuCopy,
|
||||
LuDownload,
|
||||
LuFingerprint,
|
||||
LuGlobe,
|
||||
LuGroup,
|
||||
@@ -39,6 +42,12 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
@@ -107,9 +116,9 @@ function _OSIcon({ os }: { os: string }) {
|
||||
|
||||
function InfoCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/50 border px-3 py-2.5">
|
||||
<div className="rounded-md border bg-muted/50 px-3 py-2.5">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-sm mt-0.5 truncate">{value}</p>
|
||||
<p className="mt-0.5 truncate text-sm">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -263,9 +272,9 @@ export function ProfileInfoDialog({
|
||||
? vpnConfigs.find((v) => v.id === profile.vpn_id)?.name
|
||||
: null;
|
||||
const networkLabel = vpnName
|
||||
? `VPN: ${vpnName}`
|
||||
? t("profileInfo.network.vpnLabel", { name: vpnName })
|
||||
: proxyName
|
||||
? `Proxy: ${proxyName}`
|
||||
? t("profileInfo.network.proxyLabel", { name: proxyName })
|
||||
: t("profileInfo.values.none");
|
||||
|
||||
const syncStatus = syncStatuses[profile.id];
|
||||
@@ -299,6 +308,10 @@ export function ProfileInfoDialog({
|
||||
// `ProfileDnsBlocklistDialog` for the pattern). The settings tab is purely
|
||||
// a navigation hub.
|
||||
interface ActionItem {
|
||||
// Stable, language-independent key used to map sidebar sections to actions.
|
||||
// The sidebar must NOT match on `label` — labels are translated, so English
|
||||
// substring matching hides sections for every non-English user.
|
||||
id?: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
@@ -311,6 +324,7 @@ export function ProfileInfoDialog({
|
||||
|
||||
const actions: ActionItem[] = [
|
||||
{
|
||||
id: "network",
|
||||
icon: <LuGlobe className="size-4" />,
|
||||
label: t("profiles.actions.viewNetwork"),
|
||||
onClick: () => {
|
||||
@@ -319,6 +333,7 @@ export function ProfileInfoDialog({
|
||||
disabled: isCrossOs,
|
||||
},
|
||||
{
|
||||
id: "sync",
|
||||
icon: <LuRefreshCw className="size-4" />,
|
||||
label: t("profiles.actions.syncSettings"),
|
||||
onClick: () => {
|
||||
@@ -337,6 +352,7 @@ export function ProfileInfoDialog({
|
||||
runningBadge: isRunning,
|
||||
},
|
||||
{
|
||||
id: "fingerprint",
|
||||
icon: <LuFingerprint className="size-4" />,
|
||||
label: t("profiles.actions.changeFingerprint"),
|
||||
onClick: () => {
|
||||
@@ -359,6 +375,7 @@ export function ProfileInfoDialog({
|
||||
hidden: profile.browser !== "wayfern" || !onLaunchWithSync,
|
||||
},
|
||||
{
|
||||
id: "cookiesCopy",
|
||||
icon: <LuCopy className="size-4" />,
|
||||
label: t("profiles.actions.copyCookiesToProfile"),
|
||||
onClick: () => {
|
||||
@@ -372,6 +389,7 @@ export function ProfileInfoDialog({
|
||||
!onCopyCookiesToProfile,
|
||||
},
|
||||
{
|
||||
id: "cookiesManage",
|
||||
icon: <LuCookie className="size-4" />,
|
||||
label: t("profileInfo.actions.manageCookies"),
|
||||
onClick: () => {
|
||||
@@ -395,6 +413,7 @@ export function ProfileInfoDialog({
|
||||
hidden: profile.ephemeral === true,
|
||||
},
|
||||
{
|
||||
id: "extension",
|
||||
icon: <LuPuzzle className="size-4" />,
|
||||
label: t("profileInfo.actions.assignExtensionGroup"),
|
||||
onClick: () => {
|
||||
@@ -419,6 +438,7 @@ export function ProfileInfoDialog({
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "hook",
|
||||
icon: <LuLink className="size-4" />,
|
||||
label: t("profiles.actions.launchHook"),
|
||||
onClick: () => {
|
||||
@@ -461,6 +481,7 @@ export function ProfileInfoDialog({
|
||||
destructive: true,
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
icon: <LuTrash2 className="size-4" />,
|
||||
label: t("profiles.actions.delete"),
|
||||
onClick: () => {
|
||||
@@ -482,7 +503,7 @@ export function ProfileInfoDialog({
|
||||
>
|
||||
<DialogContent
|
||||
hideClose
|
||||
className="sm:max-w-3xl w-[720px] max-w-[720px] h-[480px] max-h-[480px] flex flex-col p-0 gap-0 overflow-hidden"
|
||||
className="flex h-[min(clamp(30rem,80vh,48rem),calc(100vh-3rem))] max-w-[min(60rem,calc(100%-4rem))] flex-col gap-0 overflow-hidden p-0"
|
||||
>
|
||||
{/* The dialog renders its own custom header, so the accessible title is
|
||||
visually hidden but present for screen readers (Radix requires it). */}
|
||||
@@ -534,6 +555,7 @@ interface ProfileInfoLayoutProps {
|
||||
onCloneProfile?: (profile: BrowserProfile) => void;
|
||||
onKillProfile?: (profile: BrowserProfile) => void;
|
||||
visibleActions: {
|
||||
id?: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
@@ -579,22 +601,23 @@ function ProfileInfoLayout({
|
||||
}: ProfileInfoLayoutProps) {
|
||||
const [section, setSection] = React.useState<ProfileSection>("overview");
|
||||
|
||||
// Map sidebar items to existing action labels, so clicking a section
|
||||
// simply triggers the existing dialog handler.
|
||||
// Map sidebar items to existing actions by their stable, language-independent
|
||||
// `id`, so clicking a section triggers the existing dialog handler. Matching
|
||||
// on `label` would break for every non-English locale (the labels are
|
||||
// translated) and hide whole sections.
|
||||
const findAction = React.useCallback(
|
||||
(substr: string) =>
|
||||
visibleActions.find((a) => a.label.toLowerCase().includes(substr)),
|
||||
(id: string) => visibleActions.find((a) => a.id === id),
|
||||
[visibleActions],
|
||||
);
|
||||
|
||||
const deleteAction = findAction("delete");
|
||||
const fingerprintAction = findAction("fingerprint");
|
||||
const cookiesManageAction = findAction("manage cookies");
|
||||
const cookiesCopyAction = findAction("copy cookies");
|
||||
const cookiesManageAction = findAction("cookiesManage");
|
||||
const cookiesCopyAction = findAction("cookiesCopy");
|
||||
const cookiesAction = cookiesManageAction ?? cookiesCopyAction;
|
||||
const extensionAction = findAction("extension");
|
||||
const syncAction = findAction("sync");
|
||||
const _launchHookAction = findAction("hook") ?? findAction("launch hook");
|
||||
const _launchHookAction = findAction("hook");
|
||||
const _networkAction = findAction("network");
|
||||
// Password actions are no longer routed via the legacy action handlers —
|
||||
// SecuritySectionInline writes directly to the backend instead.
|
||||
@@ -697,20 +720,20 @@ function ProfileInfoLayout({
|
||||
return (
|
||||
<>
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center gap-2 h-11 px-3 border-b border-border 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">
|
||||
<div className="flex h-11 shrink-0 items-center gap-2 border-b border-border px-3">
|
||||
<LuUsers className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 text-xs">
|
||||
<span className="font-semibold">
|
||||
{t("profileInfo.breadcrumbRoot")}
|
||||
</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-muted-foreground truncate">{profile.name}</span>
|
||||
<span className="truncate text-muted-foreground">{profile.name}</span>
|
||||
</div>
|
||||
{onCloneProfile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs gap-1.5"
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
disabled={isDisabled}
|
||||
onClick={() => onCloneProfile(profile)}
|
||||
>
|
||||
@@ -722,16 +745,16 @@ function ProfileInfoLayout({
|
||||
type="button"
|
||||
aria-label={t("common.buttons.close")}
|
||||
onClick={onClose}
|
||||
className="grid place-items-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors duration-100"
|
||||
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors duration-100 hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<LuX className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Sidebar */}
|
||||
<nav className="w-44 shrink-0 border-r border-border p-2 flex flex-col gap-0.5 overflow-y-auto">
|
||||
<nav className="flex w-44 shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-border p-2">
|
||||
{sidebarItems
|
||||
.filter((it) => !it.hidden)
|
||||
.map((it) => {
|
||||
@@ -742,16 +765,16 @@ function ProfileInfoLayout({
|
||||
type="button"
|
||||
onClick={() => setSection(it.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 h-7 px-2 rounded-md text-xs transition-colors duration-100 text-left",
|
||||
"flex h-7 items-center gap-2 rounded-md px-2 text-left text-xs transition-colors duration-100",
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0">{it.icon}</span>
|
||||
<span className="flex-1 truncate">{it.label}</span>
|
||||
{it.badge && (
|
||||
<span className="text-[9px] uppercase text-muted-foreground tracking-wide truncate max-w-[60px]">
|
||||
<span className="max-w-[60px] truncate text-[9px] tracking-wide text-muted-foreground uppercase">
|
||||
{it.badge}
|
||||
</span>
|
||||
)}
|
||||
@@ -765,7 +788,7 @@ function ProfileInfoLayout({
|
||||
type="button"
|
||||
onClick={deleteAction.onClick}
|
||||
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"
|
||||
className="flex h-7 items-center gap-2 rounded-md px-2 text-xs text-destructive transition-colors duration-100 hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<LuTrash2 className="size-3.5 shrink-0" />
|
||||
<span className="flex-1 text-left">
|
||||
@@ -777,21 +800,21 @@ function ProfileInfoLayout({
|
||||
</nav>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 min-w-0 overflow-y-auto scroll-fade p-4">
|
||||
<div className="scroll-fade min-w-0 flex-1 overflow-y-auto p-4">
|
||||
{section === "overview" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Hero */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-muted p-2.5 shrink-0">
|
||||
<div className="shrink-0 rounded-lg bg-muted p-2.5">
|
||||
<ProfileIcon className="size-7 text-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="text-base font-semibold truncate">
|
||||
<h3 className="truncate text-base font-semibold">
|
||||
{profile.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px]">
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{profile.version}
|
||||
</span>
|
||||
@@ -800,17 +823,17 @@ function ProfileInfoLayout({
|
||||
</div>
|
||||
|
||||
{/* ID */}
|
||||
<div className="flex items-center gap-2 rounded-md bg-muted/40 px-3 py-2 border border-border">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground shrink-0">
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<span className="shrink-0 text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
ID
|
||||
</span>
|
||||
<span className="font-mono text-xs truncate flex-1">
|
||||
<span className="flex-1 truncate font-mono text-xs">
|
||||
{profile.id}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopyId()}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={t("common.buttons.copy")}
|
||||
>
|
||||
{copied ? (
|
||||
@@ -851,10 +874,21 @@ function ProfileInfoLayout({
|
||||
|
||||
{/* Activity */}
|
||||
<div className="mt-1 flex flex-col gap-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<span className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("profileInfo.sections.activity")}
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<InfoCard
|
||||
label={t("profileInfo.fields.created")}
|
||||
value={
|
||||
profile.created_at
|
||||
? new Date(profile.created_at * 1000).toLocaleString(
|
||||
undefined,
|
||||
{ dateStyle: "medium", timeStyle: "short" },
|
||||
)
|
||||
: t("profileInfo.values.unknown")
|
||||
}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t("profileInfo.fields.lastLaunched")}
|
||||
value={
|
||||
@@ -870,11 +904,11 @@ function ProfileInfoLayout({
|
||||
</div>
|
||||
|
||||
{profile.created_by_email && (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("sync.team.title")}
|
||||
</p>
|
||||
<p className="text-sm mt-0.5">
|
||||
<p className="mt-0.5 text-sm">
|
||||
{t("sync.team.createdBy", {
|
||||
email: profile.created_by_email,
|
||||
})}
|
||||
@@ -980,7 +1014,7 @@ function _SectionPlaceholder({
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
{hint && (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2 text-xs">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-xs">
|
||||
{hint}
|
||||
</div>
|
||||
)}
|
||||
@@ -988,7 +1022,7 @@ function _SectionPlaceholder({
|
||||
size="sm"
|
||||
onClick={onAction}
|
||||
disabled={disabled}
|
||||
className="self-start h-7 text-xs"
|
||||
className="h-7 self-start text-xs"
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
@@ -1015,11 +1049,11 @@ function _SectionAction({
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-2 h-9 px-3 rounded-md text-xs transition-colors text-left",
|
||||
"flex h-9 items-center gap-2 rounded-md px-3 text-left text-xs transition-colors",
|
||||
destructive
|
||||
? "text-destructive hover:bg-destructive/10"
|
||||
: "hover:bg-accent",
|
||||
"disabled:opacity-50 disabled:pointer-events-none",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
@@ -1087,7 +1121,7 @@ function LaunchHookEditor({
|
||||
setValue(e.target.value);
|
||||
}}
|
||||
placeholder={t("profiles.launchHook.placeholder")}
|
||||
className="text-xs font-mono"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{showInvalidHint && (
|
||||
<p className="text-xs text-warning">
|
||||
@@ -1149,7 +1183,7 @@ function SyncSectionInline({
|
||||
syncMode: mode,
|
||||
});
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setError(translateBackendError(t as never, e));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -1165,7 +1199,7 @@ function SyncSectionInline({
|
||||
{t("profileInfo.sectionDesc.sync")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground shrink-0">
|
||||
<span className="shrink-0 text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("profileInfo.fields.syncMode")}
|
||||
</span>
|
||||
<Select
|
||||
@@ -1175,7 +1209,7 @@ function SyncSectionInline({
|
||||
void onChangeMode(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs flex-1">
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1188,13 +1222,15 @@ function SyncSectionInline({
|
||||
</Select>
|
||||
</div>
|
||||
{syncStatus && (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("profileInfo.fields.syncStatus")}
|
||||
</p>
|
||||
<p className="text-sm mt-0.5">{syncStatus.status}</p>
|
||||
<p className="mt-0.5 text-sm">
|
||||
{t(`profileInfo.syncStatusValue.${syncStatus.status}`)}
|
||||
</p>
|
||||
{syncStatus.error && (
|
||||
<p className="text-xs text-destructive mt-1">{syncStatus.error}</p>
|
||||
<p className="mt-1 text-xs text-destructive">{syncStatus.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1246,7 +1282,7 @@ function NetworkSectionInline({
|
||||
setProxyId(nextId);
|
||||
if (nextId !== null) setVpnId(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setError(translateBackendError(t as never, e));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -1264,7 +1300,7 @@ function NetworkSectionInline({
|
||||
setVpnId(nextId);
|
||||
if (nextId !== null) setProxyId(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setError(translateBackendError(t as never, e));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -1281,7 +1317,7 @@ function NetworkSectionInline({
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground shrink-0 w-12">
|
||||
<span className="w-12 shrink-0 text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("profileInfo.fields.proxy")}
|
||||
</span>
|
||||
<Select
|
||||
@@ -1291,7 +1327,7 @@ function NetworkSectionInline({
|
||||
void onProxyChange(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs flex-1">
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1308,7 +1344,7 @@ function NetworkSectionInline({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground shrink-0 w-12">
|
||||
<span className="w-12 shrink-0 text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("profileInfo.fields.vpn")}
|
||||
</span>
|
||||
<Select
|
||||
@@ -1318,7 +1354,7 @@ function NetworkSectionInline({
|
||||
void onVpnChange(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs flex-1">
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1370,7 +1406,7 @@ function ExtensionsSectionInline({
|
||||
);
|
||||
if (mounted) setGroups(data);
|
||||
} catch (e) {
|
||||
if (mounted) setError(String(e));
|
||||
if (mounted) setError(translateBackendError(t as never, e));
|
||||
}
|
||||
};
|
||||
void load();
|
||||
@@ -1384,7 +1420,7 @@ function ExtensionsSectionInline({
|
||||
mounted = false;
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const onChange = async (value: string) => {
|
||||
const next = value === "__none__" ? null : value;
|
||||
@@ -1397,7 +1433,7 @@ function ExtensionsSectionInline({
|
||||
});
|
||||
setGroupId(next);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setError(translateBackendError(t as never, e));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -1413,7 +1449,7 @@ function ExtensionsSectionInline({
|
||||
{t("profileInfo.sectionDesc.extensions")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground shrink-0 w-16">
|
||||
<span className="w-16 shrink-0 text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("profileInfo.fields.extensionGroup")}
|
||||
</span>
|
||||
<Select
|
||||
@@ -1423,7 +1459,7 @@ function ExtensionsSectionInline({
|
||||
void onChange(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs flex-1">
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1495,16 +1531,86 @@ function CookiesSectionInline({
|
||||
};
|
||||
}, [profile.id, isRunning, t]);
|
||||
|
||||
const [isExporting, setIsExporting] = React.useState(false);
|
||||
|
||||
// Export all of this profile's cookies in one of the same formats import
|
||||
// accepts (JSON or Netscape). The backend formats every cookie; we just pick
|
||||
// a destination file.
|
||||
const handleExport = React.useCallback(
|
||||
async (format: "json" | "netscape") => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const content = await invoke<string>("export_profile_cookies", {
|
||||
profileId: profile.id,
|
||||
format,
|
||||
});
|
||||
const ext = format === "json" ? "json" : "txt";
|
||||
const filePath = await save({
|
||||
defaultPath: `${profile.name}_cookies.${ext}`,
|
||||
filters: [
|
||||
{
|
||||
name: format === "json" ? "JSON" : "Text",
|
||||
extensions: [ext],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!filePath) return;
|
||||
await writeTextFile(filePath, content);
|
||||
showSuccessToast(t("cookies.export.success"));
|
||||
} catch (e) {
|
||||
showErrorToast(translateBackendError(t as never, e));
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
},
|
||||
[profile.id, profile.name, t],
|
||||
);
|
||||
|
||||
const domains = stats?.domains ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 min-h-0 flex-1">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<LuCookie className="size-4" />
|
||||
{t("profileInfo.sections.cookies")}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5"
|
||||
disabled={
|
||||
isDisabled ||
|
||||
isRunning ||
|
||||
isExporting ||
|
||||
!stats ||
|
||||
stats.total_count === 0
|
||||
}
|
||||
>
|
||||
<LuDownload className="size-3.5" />
|
||||
{t("common.buttons.export")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void handleExport("json");
|
||||
}}
|
||||
>
|
||||
{t("cookies.export.json")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void handleExport("netscape");
|
||||
}}
|
||||
>
|
||||
{t("cookies.export.netscape")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{onImportCookies && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1514,7 +1620,7 @@ function CookiesSectionInline({
|
||||
onClick={onImportCookies}
|
||||
>
|
||||
<LuUpload className="size-3.5" />
|
||||
{t("cookies.import.title")}
|
||||
{t("common.buttons.import")}
|
||||
</Button>
|
||||
)}
|
||||
{onCopyCookies && (
|
||||
@@ -1526,7 +1632,7 @@ function CookiesSectionInline({
|
||||
onClick={onCopyCookies}
|
||||
>
|
||||
<LuCopy className="size-3.5" />
|
||||
{t("profiles.actions.copyCookies")}
|
||||
{t("common.buttons.copy")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1535,18 +1641,18 @@ function CookiesSectionInline({
|
||||
{t("profileInfo.sectionDesc.cookies")}
|
||||
</p>
|
||||
{isRunning ? (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("profileInfo.cookies.runningNotice")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("profileInfo.fields.cookieCount")}
|
||||
</p>
|
||||
<p className="text-sm mt-0.5">
|
||||
<p className="mt-0.5 text-sm">
|
||||
{isLoading
|
||||
? t("profileInfo.values.loading")
|
||||
: stats
|
||||
@@ -1555,13 +1661,13 @@ function CookiesSectionInline({
|
||||
</p>
|
||||
</div>
|
||||
{domains.length > 0 && (
|
||||
<div className="rounded-md bg-muted/40 border border-border flex flex-col min-h-0 flex-1 overflow-hidden">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground px-3 py-2 border-b border-border shrink-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-muted/40">
|
||||
<p className="shrink-0 border-b border-border px-3 py-2 text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("profileInfo.cookies.domainsHeader", {
|
||||
count: domains.length,
|
||||
})}
|
||||
</p>
|
||||
<ul className="text-xs px-3 py-2 overflow-y-auto flex-1 space-y-1">
|
||||
<ul className="flex-1 space-y-1 overflow-y-auto px-3 py-2 text-xs">
|
||||
{domains.map((d) => (
|
||||
<li
|
||||
key={d.domain}
|
||||
@@ -1684,7 +1790,7 @@ function FingerprintSectionInline({
|
||||
// Close the dialog once the fingerprint is saved.
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setError(translateBackendError(t as never, e));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -1736,7 +1842,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 mt-3 pt-3 border-t border-border">
|
||||
<div className="mt-3 flex items-center gap-2 border-t border-border pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
@@ -1907,8 +2013,8 @@ function SecuritySectionInline({
|
||||
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",
|
||||
"h-7 flex-1 rounded-md border px-2 text-xs transition-colors",
|
||||
"border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t("profilePassword.modes.validate")}
|
||||
@@ -1920,10 +2026,10 @@ function SecuritySectionInline({
|
||||
reset();
|
||||
}}
|
||||
className={cn(
|
||||
"flex-1 h-7 px-2 text-xs rounded-md border transition-colors",
|
||||
"h-7 flex-1 rounded-md border px-2 text-xs transition-colors",
|
||||
mode === "change"
|
||||
? "bg-accent text-accent-foreground border-transparent"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
||||
? "border-transparent bg-accent text-accent-foreground"
|
||||
: "border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t("profilePassword.modes.change")}
|
||||
@@ -1935,10 +2041,10 @@ function SecuritySectionInline({
|
||||
reset();
|
||||
}}
|
||||
className={cn(
|
||||
"flex-1 h-7 px-2 text-xs rounded-md border transition-colors",
|
||||
"h-7 flex-1 rounded-md border px-2 text-xs transition-colors",
|
||||
mode === "remove"
|
||||
? "bg-destructive/10 text-destructive border-transparent"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
||||
? "border-transparent bg-destructive/10 text-destructive"
|
||||
: "border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t("profilePassword.modes.remove")}
|
||||
@@ -2000,7 +2106,7 @@ function SecuritySectionInline({
|
||||
<Button
|
||||
size="sm"
|
||||
variant={mode === "remove" ? "destructive" : "default"}
|
||||
className="self-start h-7 text-xs"
|
||||
className="h-7 self-start text-xs"
|
||||
disabled={isRunning || isSubmitting}
|
||||
onClick={() => {
|
||||
void onSubmit();
|
||||
@@ -2291,11 +2397,11 @@ export function ProfileBypassRulesDialog({
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg max-h-[80vh] flex flex-col">
|
||||
<DialogContent className="flex max-h-[80vh] flex-col sm:max-w-lg">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("profileInfo.network.bypassRulesTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("profileInfo.network.bypassRulesDescription")}
|
||||
@@ -2317,12 +2423,12 @@ export function ProfileBypassRulesDialog({
|
||||
onClick={handleAddRule}
|
||||
disabled={!newRule.trim()}
|
||||
>
|
||||
<LuPlus className="size-4 mr-1" />
|
||||
<LuPlus className="mr-1 size-4" />
|
||||
{t("profileInfo.network.addRule")}
|
||||
</Button>
|
||||
</div>
|
||||
{bypassRules.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
<p className="py-2 text-sm text-muted-foreground">
|
||||
{t("profileInfo.network.noRules")}
|
||||
</p>
|
||||
) : (
|
||||
@@ -2330,15 +2436,15 @@ export function ProfileBypassRulesDialog({
|
||||
{bypassRules.map((rule) => (
|
||||
<div
|
||||
key={rule}
|
||||
className="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md bg-muted text-sm"
|
||||
className="flex items-center justify-between gap-2 rounded-md bg-muted px-3 py-1.5 text-sm"
|
||||
>
|
||||
<span className="font-mono text-xs truncate">{rule}</span>
|
||||
<span className="truncate font-mono text-xs">{rule}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleRemoveRule(rule);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-destructive"
|
||||
>
|
||||
<LuX className="size-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -193,7 +193,7 @@ export function ProfilePasswordDialog({
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(titleKey)}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -203,7 +203,7 @@ export function ProfilePasswordDialog({
|
||||
<div className="flex flex-col gap-3">
|
||||
{(mode === "set" || mode === "change") && (
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">
|
||||
<p className="font-medium text-warning-foreground">
|
||||
<p className="font-medium text-warning">
|
||||
{t("profilePassword.warnings.forgetWarningTitle")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
|
||||
@@ -171,7 +171,7 @@ export function ProfileSelectorDialog({
|
||||
<div className="grid gap-4 py-4">
|
||||
{url && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("profileSelector.openingUrl")}
|
||||
</Label>
|
||||
@@ -180,7 +180,7 @@ export function ProfileSelectorDialog({
|
||||
successMessage={t("profileSelector.urlCopied")}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 text-sm break-all rounded bg-muted">
|
||||
<div className="max-h-24 overflow-y-auto rounded bg-muted p-2 text-sm break-all">
|
||||
{url}
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,8 +230,8 @@ export function ProfileSelectorDialog({
|
||||
!canUseForLinks ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-3 items-center px-2 py-1 rounded-lg">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-3 rounded-lg px-2 py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const IconComponent = getBrowserIcon(
|
||||
profile.browser,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { getEntitlements } from "@/lib/entitlements";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, SyncMode, SyncSettings } from "@/types";
|
||||
import { isSyncEnabled } from "@/types";
|
||||
@@ -36,11 +37,7 @@ export function ProfileSyncDialog({
|
||||
}: ProfileSyncDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user: cloudUser } = useCloudAuth();
|
||||
const isCloudSyncEligible =
|
||||
cloudUser != null &&
|
||||
cloudUser.plan !== "free" &&
|
||||
(cloudUser.subscriptionStatus === "active" ||
|
||||
cloudUser.planPeriod === "lifetime");
|
||||
const isCloudSyncEligible = getEntitlements(cloudUser).cloudBackup;
|
||||
// Encryption available to everyone except team members who aren't owners
|
||||
const canUseEncryption =
|
||||
cloudUser == null ||
|
||||
@@ -175,8 +172,8 @@ export function ProfileSyncDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-w-md flex-col overflow-hidden">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("sync.mode.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sync.mode.description", {
|
||||
@@ -186,115 +183,117 @@ export function ProfileSyncDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isCheckingConfig ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 py-4">
|
||||
{!hasConfig && (
|
||||
<div className="p-3 text-sm rounded-md bg-muted">
|
||||
<p className="mb-2">{t("sync.mode.notConfigured")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onSyncConfigOpen();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t("sync.mode.configureService")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasConfig && (
|
||||
<>
|
||||
<RadioGroup
|
||||
value={syncMode}
|
||||
onValueChange={handleModeChange}
|
||||
disabled={isSaving}
|
||||
className="grid gap-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">
|
||||
{t("sync.mode.disabled")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sync.mode.disabledDescription")}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{t("sync.mode.regular")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sync.mode.regularDescription")}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-x-3">
|
||||
<RadioGroupItem
|
||||
value="Encrypted"
|
||||
id="sync-encrypted"
|
||||
disabled={!canUseEncryption}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="sync-encrypted"
|
||||
className={
|
||||
canUseEncryption
|
||||
? "cursor-pointer"
|
||||
: "cursor-not-allowed opacity-50"
|
||||
}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{t("sync.mode.encrypted")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{canUseEncryption
|
||||
? t("sync.mode.encryptedDescription")
|
||||
: t("settings.encryption.requiresProOrOwner")}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{syncMode === "Encrypted" &&
|
||||
!hasE2ePassword &&
|
||||
userChangedMode && (
|
||||
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
|
||||
{t("sync.mode.noPasswordWarning")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("sync.mode.lastSynced")}</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Badge variant="outline">
|
||||
{formatLastSync(profile.last_sync)}
|
||||
</Badge>
|
||||
{isSyncEnabled(profile) && (
|
||||
<Badge
|
||||
variant={profile.last_sync ? "default" : "secondary"}
|
||||
>
|
||||
{profile.last_sync
|
||||
? t("common.status.synced")
|
||||
: t("common.status.pending")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{isCheckingConfig ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="size-6 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 py-4">
|
||||
{!hasConfig && (
|
||||
<div className="rounded-md bg-muted p-3 text-sm">
|
||||
<p className="mb-2">{t("sync.mode.notConfigured")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onSyncConfigOpen();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t("sync.mode.configureService")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{hasConfig && (
|
||||
<>
|
||||
<RadioGroup
|
||||
value={syncMode}
|
||||
onValueChange={handleModeChange}
|
||||
disabled={isSaving}
|
||||
className="grid gap-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">
|
||||
{t("sync.mode.disabled")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sync.mode.disabledDescription")}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{t("sync.mode.regular")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sync.mode.regularDescription")}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-x-3">
|
||||
<RadioGroupItem
|
||||
value="Encrypted"
|
||||
id="sync-encrypted"
|
||||
disabled={!canUseEncryption}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="sync-encrypted"
|
||||
className={
|
||||
canUseEncryption
|
||||
? "cursor-pointer"
|
||||
: "cursor-not-allowed opacity-50"
|
||||
}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{t("sync.mode.encrypted")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{canUseEncryption
|
||||
? t("sync.mode.encryptedDescription")
|
||||
: t("settings.encryption.requiresProOrOwner")}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{syncMode === "Encrypted" &&
|
||||
!hasE2ePassword &&
|
||||
userChangedMode && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{t("sync.mode.noPasswordWarning")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("sync.mode.lastSynced")}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{formatLastSync(profile.last_sync)}
|
||||
</Badge>
|
||||
{isSyncEnabled(profile) && (
|
||||
<Badge
|
||||
variant={profile.last_sync ? "default" : "secondary"}
|
||||
>
|
||||
{profile.last_sync
|
||||
? t("common.status.synced")
|
||||
: t("common.status.pending")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
|
||||
@@ -157,8 +157,8 @@ export function ProxyAssignmentDialog({
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("proxyAssignment.selectedProfilesLabel")}</Label>
|
||||
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
<div className="max-h-[min(8rem,20vh)] overflow-y-auto rounded-md bg-muted p-3">
|
||||
<ul className="space-y-1 text-sm">
|
||||
{selectedProfiles.map((profileId) => {
|
||||
const profile = profiles.find(
|
||||
(p: BrowserProfile) => p.id === profileId,
|
||||
@@ -206,7 +206,7 @@ export function ProxyAssignmentDialog({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
id={proxyListboxId}
|
||||
className="w-[240px] p-0"
|
||||
className="w-(--radix-popover-trigger-width) p-0"
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command>
|
||||
@@ -283,7 +283,7 @@ export function ProxyAssignmentDialog({
|
||||
/>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
className="mr-1 px-1 py-0 text-[10px] leading-tight"
|
||||
>
|
||||
WG
|
||||
</Badge>
|
||||
@@ -299,7 +299,7 @@ export function ProxyAssignmentDialog({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -123,14 +123,14 @@ export function ProxyCheckButton({
|
||||
disabled={isCurrentlyChecking || disabled}
|
||||
>
|
||||
{isCurrentlyChecking ? (
|
||||
<div className="size-3 rounded-full border border-current animate-spin border-t-transparent" />
|
||||
<div className="size-3 animate-spin rounded-full border border-current 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" />
|
||||
<FiCheck className="absolute bottom-[-6px] right-[-4px]" />
|
||||
<FiCheck className="absolute right-[-4px] bottom-[-6px]" />
|
||||
</span>
|
||||
) : result && !result.is_valid ? (
|
||||
<span className="text-destructive text-sm">✕</span>
|
||||
<span className="text-sm text-destructive">✕</span>
|
||||
) : (
|
||||
<FiCheck className="size-3" />
|
||||
)}
|
||||
|
||||
@@ -90,7 +90,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("proxies.exportDialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -125,17 +125,17 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("proxies.exportDialog.preview")}</Label>
|
||||
<ScrollArea className="h-[200px] border rounded-md bg-muted/30">
|
||||
<ScrollArea className="h-[clamp(120px,30vh,400px)] rounded-md border bg-muted/30">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
|
||||
<div className="flex h-full items-center justify-center p-4 text-sm text-muted-foreground">
|
||||
{t("common.buttons.loading")}
|
||||
</div>
|
||||
) : exportContent ? (
|
||||
<pre className="p-3 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
|
||||
{exportContent}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
|
||||
<div className="flex h-full items-center justify-center p-4 text-sm text-muted-foreground">
|
||||
{t("proxies.exportDialog.noProxies")}
|
||||
</div>
|
||||
)}
|
||||
@@ -143,7 +143,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<DialogFooter className="flex-col gap-2 sm:flex-row">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
@@ -151,7 +151,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
variant="outline"
|
||||
onClick={() => void handleCopyToClipboard()}
|
||||
disabled={!exportContent || isLoading}
|
||||
className="flex gap-2 items-center"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{copied ? (
|
||||
<LuCheck className="size-4" />
|
||||
@@ -165,7 +165,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
<RippleButton
|
||||
onClick={handleDownload}
|
||||
disabled={!exportContent || isLoading}
|
||||
className="flex gap-2 items-center"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<LuDownload className="size-4" />
|
||||
{t("common.buttons.download")}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import type { StoredProxy } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -127,9 +128,11 @@ export function ProxyFormDialog({
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save proxy:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(t("proxies.form.saveFailed", { error: errorMessage }));
|
||||
toast.error(
|
||||
t("proxies.form.saveFailed", {
|
||||
error: translateBackendError(t, error),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -158,7 +161,7 @@ export function ProxyFormDialog({
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="@container grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-name">{t("proxies.form.name")}</Label>
|
||||
<Input
|
||||
@@ -228,12 +231,12 @@ export function ProxyFormDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 @sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-username">
|
||||
{form.proxy_type === "ss"
|
||||
? t("proxies.form.cipher")
|
||||
: `${t("proxies.form.username")} (${t("proxies.form.usernamePlaceholder")})`}
|
||||
: t("proxies.form.username")}
|
||||
</Label>
|
||||
<Input
|
||||
id="proxy-username"
|
||||
@@ -252,9 +255,7 @@ export function ProxyFormDialog({
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-password">
|
||||
{form.proxy_type === "ss"
|
||||
? t("proxies.form.password")
|
||||
: `${t("proxies.form.password")} (${t("proxies.form.passwordPlaceholder")})`}
|
||||
{t("proxies.form.password")}
|
||||
</Label>
|
||||
<Input
|
||||
id="proxy-password"
|
||||
|
||||
@@ -280,7 +280,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("proxies.importDialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -315,8 +315,8 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LuUpload className="size-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
<LuUpload className="mb-4 size-10 text-muted-foreground" />
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t("proxies.importDialog.dropzonePrompt")}
|
||||
<br />
|
||||
<span className="text-xs">
|
||||
@@ -335,7 +335,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t("proxies.importDialog.pasteHint", { modKey })}
|
||||
</p>
|
||||
</div>
|
||||
@@ -369,19 +369,19 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
count: parsedProxies.length,
|
||||
})}
|
||||
{invalidProxies.length > 0 && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{t("proxies.importDialog.invalidCount", {
|
||||
count: invalidProxies.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<ScrollArea className="h-[200px] border rounded-md">
|
||||
<div className="p-2 space-y-1">
|
||||
<ScrollArea className="h-[clamp(120px,30vh,400px)] rounded-md border">
|
||||
<div className="space-y-1 p-2">
|
||||
{parsedProxies.map((proxy, i) => (
|
||||
<div
|
||||
key={`${proxy.original_line}-${i}`}
|
||||
className="text-xs font-mono p-2 bg-muted/30 rounded"
|
||||
className="rounded bg-muted/30 p-2 font-mono text-xs break-all"
|
||||
>
|
||||
<span className="text-primary">
|
||||
{proxy.proxy_type}://
|
||||
@@ -407,21 +407,21 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("proxies.importDialog.ambiguousIntro")}
|
||||
</p>
|
||||
<ScrollArea className="h-[250px] border rounded-md">
|
||||
<div className="p-3 space-y-4">
|
||||
<ScrollArea className="h-[clamp(150px,35vh,450px)] rounded-md border">
|
||||
<div className="space-y-4 p-3">
|
||||
{ambiguousProxies.map((proxy, i) => (
|
||||
<div
|
||||
key={`${proxy.line}-${i}`}
|
||||
className="space-y-2 pb-3 border-b last:border-0"
|
||||
className="space-y-2 border-b pb-3 last:border-0"
|
||||
>
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded block">
|
||||
<code className="block rounded bg-muted px-2 py-1 text-xs break-all">
|
||||
{proxy.line}
|
||||
</code>
|
||||
<div className="flex flex-col gap-2">
|
||||
{proxy.possible_formats.map((format) => (
|
||||
<label
|
||||
key={format}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
@@ -445,7 +445,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
|
||||
{step === "result" && importResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-muted/30 rounded-lg space-y-2">
|
||||
<div className="space-y-2 rounded-lg bg-muted/30 p-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">
|
||||
{t("proxies.importDialog.imported")}
|
||||
@@ -479,8 +479,8 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("proxies.importDialog.errors")}</Label>
|
||||
<ScrollArea className="h-[100px] border rounded-md">
|
||||
<div className="p-2 space-y-1">
|
||||
<ScrollArea className="h-[100px] rounded-md border">
|
||||
<div className="space-y-1 p-2">
|
||||
{importResult.errors.map((error, i) => (
|
||||
<div
|
||||
key={`error-${i}`}
|
||||
|
||||
@@ -504,6 +504,7 @@ export function ProxyManagementDialog({
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: 28,
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
@@ -540,7 +541,7 @@ export function ProxyManagementDialog({
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
className="h-auto cursor-pointer justify-start p-0 text-left font-semibold"
|
||||
>
|
||||
{t("common.labels.name")}
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
@@ -551,21 +552,36 @@ export function ProxyManagementDialog({
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.name}</span>
|
||||
<span className="block truncate font-medium">
|
||||
{row.original.name}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "protocol",
|
||||
size: 96,
|
||||
enableSorting: false,
|
||||
header: () => t("proxies.management.protocolCol"),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<span className="font-mono text-[10px] tracking-wider text-muted-foreground uppercase">
|
||||
{row.original.proxy_settings.proxy_type}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "hostPort",
|
||||
enableSorting: false,
|
||||
header: () => t("proxies.management.hostPort"),
|
||||
cell: ({ row }) => (
|
||||
<span className="block truncate font-mono text-xs text-muted-foreground">
|
||||
{row.original.proxy_settings.host}:
|
||||
{row.original.proxy_settings.port}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "usage",
|
||||
size: 80,
|
||||
enableSorting: false,
|
||||
header: () => t("proxies.management.usage"),
|
||||
cell: ({ row }) => (
|
||||
@@ -574,6 +590,7 @@ export function ProxyManagementDialog({
|
||||
},
|
||||
{
|
||||
id: "sync",
|
||||
size: 96,
|
||||
enableSorting: false,
|
||||
header: () => t("proxies.management.syncCol"),
|
||||
cell: ({ row }) => {
|
||||
@@ -607,6 +624,7 @@ export function ProxyManagementDialog({
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 144,
|
||||
enableSorting: false,
|
||||
header: () => t("common.labels.actions"),
|
||||
cell: ({ row }) => {
|
||||
@@ -756,7 +774,7 @@ export function ProxyManagementDialog({
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
className="h-auto cursor-pointer justify-start p-0 text-left font-semibold"
|
||||
>
|
||||
{t("common.labels.name")}
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
@@ -775,7 +793,7 @@ export function ProxyManagementDialog({
|
||||
vpnSyncErrors[vpn.id],
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<div className="flex min-w-0 items-center gap-2 font-medium">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
@@ -788,19 +806,21 @@ export function ProxyManagementDialog({
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{vpn.name}
|
||||
<span className="truncate">{vpn.name}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
size: 96,
|
||||
enableSorting: false,
|
||||
header: () => t("common.labels.type"),
|
||||
cell: () => <Badge variant="outline">WG</Badge>,
|
||||
},
|
||||
{
|
||||
id: "usage",
|
||||
size: 80,
|
||||
enableSorting: false,
|
||||
header: () => t("proxies.management.usage"),
|
||||
cell: ({ row }) => (
|
||||
@@ -809,6 +829,7 @@ export function ProxyManagementDialog({
|
||||
},
|
||||
{
|
||||
id: "sync",
|
||||
size: 96,
|
||||
enableSorting: false,
|
||||
header: () => t("proxies.management.syncCol"),
|
||||
cell: ({ row }) => {
|
||||
@@ -842,6 +863,7 @@ export function ProxyManagementDialog({
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 144,
|
||||
enableSorting: false,
|
||||
header: () => t("common.labels.actions"),
|
||||
cell: ({ row }) => {
|
||||
@@ -1068,7 +1090,7 @@ export function ProxyManagementDialog({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
|
||||
<DialogContent className="flex max-h-[85vh] max-w-[min(80rem,calc(100%-4rem))] flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
|
||||
@@ -1078,251 +1100,355 @@ export function ProxyManagementDialog({
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<AnimatedTabs
|
||||
key={initialTab}
|
||||
defaultValue={initialTab}
|
||||
onValueChange={(v) => setActiveTab(v as "proxies" | "vpns")}
|
||||
className="flex-1 min-h-0 flex flex-col"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 shrink-0">
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="proxies">
|
||||
<span>{t("proxies.management.tabProxies")}</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{storedProxies.length}
|
||||
</span>
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="vpns">
|
||||
<span>{t("proxies.management.tabVpns")}</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{vpnConfigs.length}
|
||||
</span>
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeTab === "proxies" && (
|
||||
<>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowImportDialog(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="size-4" />
|
||||
{t("common.buttons.import")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowExportDialog(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
disabled={storedProxies.length === 0}
|
||||
>
|
||||
<LuDownload className="size-4" />
|
||||
{t("common.buttons.export")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateProxy}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
{t("proxies.management.newProxy")}
|
||||
</RippleButton>
|
||||
</>
|
||||
)}
|
||||
{activeTab === "vpns" && (
|
||||
<>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowVpnImportDialog(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="size-4" />
|
||||
{t("common.buttons.import")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateVpn}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
{t("proxies.management.newVpn")}
|
||||
</RippleButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="proxies"
|
||||
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
|
||||
<div className="@container flex min-h-0 w-full flex-1 flex-col">
|
||||
<AnimatedTabs
|
||||
key={initialTab}
|
||||
defaultValue={initialTab}
|
||||
onValueChange={(v) => setActiveTab(v as "proxies" | "vpns")}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("proxies.management.loading")}
|
||||
</div>
|
||||
) : storedProxies.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("proxies.management.noneCreated")}
|
||||
</div>
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className="flex-1 min-h-0"
|
||||
style={
|
||||
{
|
||||
"--scroll-fade-top-offset": "32px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table className="w-full">
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
{proxiesTable.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,
|
||||
}}
|
||||
className={cn(
|
||||
header.column.id !== "name" &&
|
||||
header.column.id !== "select" &&
|
||||
"whitespace-nowrap w-px",
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{proxiesTable.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="proxies">
|
||||
<span>{t("proxies.management.tabProxies")}</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{storedProxies.length}
|
||||
</span>
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="vpns">
|
||||
<span>{t("proxies.management.tabVpns")}</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{vpnConfigs.length}
|
||||
</span>
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeTab === "proxies" && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowImportDialog(true);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("common.buttons.import")}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<LuUpload className="size-4" />
|
||||
<span className="hidden @2xl:inline">
|
||||
{t("common.buttons.import")}
|
||||
</span>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("common.buttons.import")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowExportDialog(true);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("common.buttons.export")}
|
||||
disabled={storedProxies.length === 0}
|
||||
>
|
||||
<LuDownload className="size-4" />
|
||||
<span className="hidden @2xl:inline">
|
||||
{t("common.buttons.export")}
|
||||
</span>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("common.buttons.export")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateProxy}
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("proxies.management.newProxy")}
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
<span className="hidden @2xl:inline">
|
||||
{t("proxies.management.newProxy")}
|
||||
</span>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("proxies.management.newProxy")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{activeTab === "vpns" && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowVpnImportDialog(true);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("common.buttons.import")}
|
||||
>
|
||||
<LuUpload className="size-4" />
|
||||
<span className="hidden @2xl:inline">
|
||||
{t("common.buttons.import")}
|
||||
</span>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("common.buttons.import")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateVpn}
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t("proxies.management.newVpn")}
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
<span className="hidden @2xl:inline">
|
||||
{t("proxies.management.newVpn")}
|
||||
</span>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("proxies.management.newVpn")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="vpns"
|
||||
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
|
||||
>
|
||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||
{isLoadingVpns ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("vpns.management.loading")}
|
||||
</div>
|
||||
) : vpnConfigs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("vpns.management.noneCreated")}
|
||||
</div>
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className="flex-1 min-h-0"
|
||||
style={
|
||||
{
|
||||
"--scroll-fade-top-offset": "32px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table className="w-full">
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
{vpnsTable.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,
|
||||
}}
|
||||
className={cn(
|
||||
header.column.id !== "name" &&
|
||||
header.column.id !== "select" &&
|
||||
"whitespace-nowrap w-px",
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vpnsTable.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>
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
<AnimatedTabsContent
|
||||
value="proxies"
|
||||
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("proxies.management.loading")}
|
||||
</div>
|
||||
) : storedProxies.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("proxies.management.noneCreated")}
|
||||
</div>
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className={cn(
|
||||
"min-h-0 flex-1",
|
||||
selectedProxies.length > 0 && "pb-16",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--scroll-fade-top-offset": "32px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table
|
||||
className="w-full table-fixed"
|
||||
containerClassName="overflow-visible"
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
{proxiesTable.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width:
|
||||
header.column.id === "name" ||
|
||||
header.column.id === "hostPort"
|
||||
? undefined
|
||||
: `${header.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
// name and hostPort emit no width, so
|
||||
// fixed layout splits the remaining
|
||||
// space evenly between them (hostPort
|
||||
// hides below @2xl, leaving name all
|
||||
// of it).
|
||||
header.column.id === "name" && "max-w-0",
|
||||
header.column.id === "hostPort" &&
|
||||
"hidden max-w-0 @2xl:table-cell",
|
||||
(header.column.id === "protocol" ||
|
||||
header.column.id === "type") &&
|
||||
"hidden @2xl:table-cell",
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{proxiesTable.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.id === "name" ||
|
||||
cell.column.id === "hostPort"
|
||||
? undefined
|
||||
: `${cell.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
cell.column.id === "name" && "max-w-0",
|
||||
cell.column.id === "hostPort" &&
|
||||
"hidden max-w-0 @2xl:table-cell",
|
||||
(cell.column.id === "protocol" ||
|
||||
cell.column.id === "type") &&
|
||||
"hidden @2xl:table-cell",
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</FadingScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="vpns"
|
||||
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
{isLoadingVpns ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("vpns.management.loading")}
|
||||
</div>
|
||||
) : vpnConfigs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("vpns.management.noneCreated")}
|
||||
</div>
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className={cn(
|
||||
"min-h-0 flex-1",
|
||||
selectedVpns.length > 0 && "pb-16",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--scroll-fade-top-offset": "32px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table
|
||||
className="w-full table-fixed"
|
||||
containerClassName="overflow-visible"
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
{vpnsTable.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width:
|
||||
header.column.id === "name" ||
|
||||
header.column.id === "hostPort"
|
||||
? undefined
|
||||
: `${header.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
// name and hostPort emit no width, so
|
||||
// fixed layout splits the remaining
|
||||
// space evenly between them (hostPort
|
||||
// hides below @2xl, leaving name all
|
||||
// of it).
|
||||
header.column.id === "name" && "max-w-0",
|
||||
header.column.id === "hostPort" &&
|
||||
"hidden max-w-0 @2xl:table-cell",
|
||||
(header.column.id === "protocol" ||
|
||||
header.column.id === "type") &&
|
||||
"hidden @2xl:table-cell",
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vpnsTable.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.id === "name" ||
|
||||
cell.column.id === "hostPort"
|
||||
? undefined
|
||||
: `${cell.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
cell.column.id === "name" && "max-w-0",
|
||||
cell.column.id === "hostPort" &&
|
||||
"hidden max-w-0 @2xl:table-cell",
|
||||
(cell.column.id === "protocol" ||
|
||||
cell.column.id === "type") &&
|
||||
"hidden @2xl:table-cell",
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</FadingScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
</div>
|
||||
|
||||
{!subPage && (
|
||||
<DialogFooter>
|
||||
|
||||
+56
-52
@@ -74,8 +74,6 @@ function useLogoEasterEgg({
|
||||
const rect = el.getBoundingClientRect();
|
||||
const startX = rect.left;
|
||||
const startY = rect.top;
|
||||
const floorY = window.innerHeight;
|
||||
const rightWall = window.innerWidth;
|
||||
|
||||
const clone = el.cloneNode(true) as HTMLElement;
|
||||
clone.style.position = "fixed";
|
||||
@@ -99,6 +97,10 @@ function useLogoEasterEgg({
|
||||
const dt = Math.min((time - lastTime) / 1000, 0.05);
|
||||
lastTime = time;
|
||||
|
||||
// Read live so a mid-animation window resize moves the floor/wall.
|
||||
const floorY = window.innerHeight;
|
||||
const rightWall = window.innerWidth;
|
||||
|
||||
vy += GRAVITY * dt;
|
||||
x += vx * dt;
|
||||
y += vy * dt;
|
||||
@@ -288,13 +290,13 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
} = useLogoEasterEgg({ currentPage, onNavigate });
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col items-center w-10 py-2 gap-1 bg-background border-r border-border shrink-0 relative">
|
||||
<nav className="relative flex w-10 shrink-0 flex-col items-center gap-1 border-r border-border bg-background py-2">
|
||||
{!isHidden ? (
|
||||
<button
|
||||
ref={logoRef}
|
||||
type="button"
|
||||
aria-label={t("header.donutLogo")}
|
||||
className="grid place-items-center size-7 rounded-md cursor-pointer select-none text-foreground bg-transparent"
|
||||
className="grid size-7 shrink-0 cursor-pointer place-items-center rounded-md bg-transparent text-foreground select-none"
|
||||
onClick={handleClick}
|
||||
onPointerDown={() => {
|
||||
setIsPressed(true);
|
||||
@@ -331,43 +333,45 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="size-7" />
|
||||
<div className="size-7 shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="w-5 h-px bg-border my-1" />
|
||||
<div className="my-1 h-px w-5 shrink-0 bg-border" />
|
||||
|
||||
{TOP_ITEMS.map(({ page, Icon, labelKey }) => {
|
||||
const active = currentPage === page;
|
||||
return (
|
||||
<Tooltip key={page} delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onNavigate(page);
|
||||
}}
|
||||
aria-label={t(labelKey)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
|
||||
/>
|
||||
)}
|
||||
<Icon className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t(labelKey)}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
<div className="flex min-h-0 w-full scrollbar-none flex-col items-center gap-1 overflow-y-auto [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
||||
{TOP_ITEMS.map(({ page, Icon, labelKey }) => {
|
||||
const active = currentPage === page;
|
||||
return (
|
||||
<Tooltip key={page} delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onNavigate(page);
|
||||
}}
|
||||
aria-label={t(labelKey)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"relative grid size-7 shrink-0 cursor-pointer place-items-center rounded-md transition-colors duration-100",
|
||||
active
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-1.5 left-[-7px] w-[2px] rounded-full bg-foreground"
|
||||
/>
|
||||
)}
|
||||
<Icon className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t(labelKey)}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
@@ -381,10 +385,10 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
aria-label={t("rail.more.label")}
|
||||
aria-expanded={moreOpen}
|
||||
className={cn(
|
||||
"grid place-items-center size-7 rounded-md transition-colors duration-100",
|
||||
"grid size-7 shrink-0 cursor-pointer place-items-center rounded-md transition-colors duration-100",
|
||||
moreOpen
|
||||
? "text-foreground bg-accent"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
|
||||
)}
|
||||
>
|
||||
<GoKebabHorizontal className="size-3.5" />
|
||||
@@ -403,16 +407,16 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
aria-label={t("rail.settings")}
|
||||
aria-current={currentPage === "settings" ? "page" : undefined}
|
||||
className={cn(
|
||||
"relative grid place-items-center size-7 rounded-md transition-colors duration-100",
|
||||
"relative grid size-7 shrink-0 cursor-pointer place-items-center rounded-md transition-colors duration-100",
|
||||
currentPage === "settings"
|
||||
? "text-foreground bg-accent"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
|
||||
)}
|
||||
>
|
||||
{currentPage === "settings" && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
|
||||
className="absolute inset-y-1.5 left-[-7px] w-[2px] rounded-full bg-foreground"
|
||||
/>
|
||||
)}
|
||||
<GoGear className="size-3.5" />
|
||||
@@ -426,12 +430,12 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("rail.more.closeAriaLabel")}
|
||||
className="fixed inset-0 z-30 bg-transparent cursor-default"
|
||||
className="fixed inset-0 z-30 cursor-default bg-transparent"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute bottom-14 left-11 w-56 bg-card border border-border rounded-lg shadow-2xl p-1 z-40 animate-in fade-in-0 slide-in-from-bottom-1 duration-100">
|
||||
<div className="absolute bottom-14 left-11 z-40 w-56 animate-in rounded-lg border border-border bg-card p-1 shadow-2xl duration-100 fade-in-0 slide-in-from-bottom-1">
|
||||
{MORE_ITEMS.map(({ page, Icon, labelKey, hintKey }) => (
|
||||
<button
|
||||
key={page}
|
||||
@@ -440,16 +444,16 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
setMoreOpen(false);
|
||||
onNavigate(page);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md hover:bg-accent transition-colors duration-100 text-left"
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-100 hover:bg-accent"
|
||||
>
|
||||
<span className="grid place-items-center size-5 rounded bg-muted text-muted-foreground shrink-0">
|
||||
<span className="grid size-5 shrink-0 place-items-center rounded bg-muted text-muted-foreground">
|
||||
<Icon className="size-3" />
|
||||
</span>
|
||||
<span className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-medium text-foreground truncate">
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{t(hintKey)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -93,10 +93,10 @@ export function ReleaseTypeSelector({
|
||||
role="combobox"
|
||||
aria-expanded={popoverOpen}
|
||||
aria-controls={listboxId}
|
||||
className="justify-between w-full"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{selectedDisplayText}
|
||||
<LuChevronsUpDown className="ml-2 size-4 opacity-50 shrink-0" />
|
||||
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</RippleButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent id={listboxId} className="p-0">
|
||||
@@ -134,7 +134,7 @@ export function ReleaseTypeSelector({
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="capitalize">{option.type}</span>
|
||||
{option.type === "nightly" && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
@@ -161,7 +161,7 @@ export function ReleaseTypeSelector({
|
||||
) : (
|
||||
// Show a simple display when only one release type is available
|
||||
releaseOptions.length === 1 && (
|
||||
<div className="flex gap-2 justify-center items-center p-3 rounded-md border bg-muted/50">
|
||||
<div className="flex items-center justify-center gap-2 rounded-md border bg-muted/50 p-3">
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{releaseOptions[0].type}
|
||||
</span>
|
||||
|
||||
@@ -194,7 +194,7 @@ export function SettingsDialog({
|
||||
return (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="text-success-foreground bg-success"
|
||||
className="bg-success text-success-foreground"
|
||||
>
|
||||
{t("common.status.granted")}
|
||||
</Badge>
|
||||
@@ -483,7 +483,8 @@ export function SettingsDialog({
|
||||
| "zh"
|
||||
| "ja"
|
||||
| "ko"
|
||||
| "ru"),
|
||||
| "ru"
|
||||
| "vi"),
|
||||
);
|
||||
setOriginalLanguage(selectedLanguage);
|
||||
}
|
||||
@@ -632,7 +633,7 @@ export function SettingsDialog({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogContent className="flex max-h-[calc(100vh-5rem)] max-w-md flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("settings.title")}</DialogTitle>
|
||||
@@ -641,8 +642,8 @@ export function SettingsDialog({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid overflow-y-auto flex-1 gap-6 min-h-0",
|
||||
subPage ? "py-2" : "py-4",
|
||||
"grid min-h-0 flex-1 gap-6 overflow-y-auto",
|
||||
subPage ? "mx-auto w-full max-w-2xl py-2" : "py-4",
|
||||
)}
|
||||
>
|
||||
{/* Appearance Section */}
|
||||
@@ -747,21 +748,21 @@ export function SettingsDialog({
|
||||
<div className="text-sm font-medium">
|
||||
{t("settings.appearance.customColors")}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(4rem,1fr))] gap-3">
|
||||
{THEME_VARIABLES.map(({ key, label }) => {
|
||||
const colorValue =
|
||||
customThemeState.colors[key] ?? "#000000";
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1 items-center"
|
||||
className="flex flex-col items-center gap-1"
|
||||
>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
className="size-8 rounded-md border shadow-sm cursor-pointer"
|
||||
className="size-8 cursor-pointer rounded-md border shadow-sm"
|
||||
style={{ backgroundColor: colorValue }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
@@ -770,7 +771,7 @@ export function SettingsDialog({
|
||||
sideOffset={6}
|
||||
>
|
||||
<ColorPicker
|
||||
className="p-3 rounded-md border shadow-sm bg-background"
|
||||
className="rounded-md border bg-background p-3 shadow-sm"
|
||||
value={colorValue}
|
||||
onColorChange={([r, g, b, a]) => {
|
||||
const next = Color({ r, g, b }).alpha(a);
|
||||
@@ -791,21 +792,21 @@ export function SettingsDialog({
|
||||
}}
|
||||
>
|
||||
<ColorPickerSelection className="h-36 rounded" />
|
||||
<div className="flex gap-3 items-center mt-3">
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<ColorPickerEyeDropper />
|
||||
<div className="grid gap-1 w-full">
|
||||
<div className="grid w-full gap-1">
|
||||
<ColorPickerHue />
|
||||
<ColorPickerAlpha />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center mt-3">
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<ColorPickerOutput />
|
||||
<ColorPickerFormat />
|
||||
</div>
|
||||
</ColorPicker>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="text-[10px] text-muted-foreground text-center leading-tight">
|
||||
<div className="text-center text-[10px] leading-tight text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
@@ -859,7 +860,7 @@ export function SettingsDialog({
|
||||
{/* Default Browser Section - hidden in portable mode */}
|
||||
{!systemInfo?.portable && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.defaultBrowser.title")}
|
||||
</Label>
|
||||
@@ -908,7 +909,7 @@ export function SettingsDialog({
|
||||
{permissions.map((permission) => (
|
||||
<div
|
||||
key={permission.permission_type}
|
||||
className="flex justify-between items-center p-3 rounded-lg border"
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-x-3">
|
||||
{getPermissionIcon(permission.permission_type)}
|
||||
@@ -1014,7 +1015,7 @@ export function SettingsDialog({
|
||||
{t("settings.encryption.passwordSetDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -1155,7 +1156,7 @@ export function SettingsDialog({
|
||||
{t("settings.commercial.title")}
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/40">
|
||||
<div className="flex items-center justify-between rounded-md border bg-muted/40 p-3">
|
||||
{cloudUser != null && cloudUser.plan !== "free" ? (
|
||||
// Paid Donut plan supersedes the local commercial trial —
|
||||
// the trial only exists to gate commercial use until the
|
||||
@@ -1204,7 +1205,7 @@ export function SettingsDialog({
|
||||
</Label>
|
||||
|
||||
{!isLinux && (
|
||||
<div className="flex items-start gap-x-3 p-3 rounded-lg border">
|
||||
<div className="flex items-start gap-x-3 rounded-lg border p-3">
|
||||
<Checkbox
|
||||
id="disable-auto-updates"
|
||||
checked={settings.disable_auto_updates ?? false}
|
||||
@@ -1226,7 +1227,7 @@ export function SettingsDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-x-3 p-3 rounded-lg border">
|
||||
<div className="flex items-start gap-x-3 rounded-lg border p-3">
|
||||
<Checkbox
|
||||
id="keep-decrypted-profiles-in-ram"
|
||||
checked={settings.keep_decrypted_profiles_in_ram ?? false}
|
||||
@@ -1304,8 +1305,8 @@ export function SettingsDialog({
|
||||
|
||||
{/* System Info */}
|
||||
{systemInfo && (
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs text-muted-foreground font-mono whitespace-pre-line select-all">
|
||||
<div className="border-t pt-2">
|
||||
<p className="font-mono text-xs whitespace-pre-line text-muted-foreground select-all">
|
||||
{`Donut Browser ${systemInfo.app_version}\n${systemInfo.os} ${systemInfo.arch}${systemInfo.portable ? " (portable)" : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1313,7 +1314,7 @@ export function SettingsDialog({
|
||||
</div>
|
||||
|
||||
{subPage ? (
|
||||
<div className="shrink-0 flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||
<div className="mx-auto flex w-full max-w-2xl shrink-0 items-center justify-end gap-2 border-t border-border pt-2">
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
isLoading={isSaving}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user