mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-11 17:27:54 +02:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 42067367fd | |||
| ce7213dccd | |||
| 799df28f61 | |||
| e501e7a260 | |||
| 801bd3fe90 | |||
| b4074c1ee6 | |||
| 08cde9c0dc | |||
| 98f1c7452a | |||
| ddfdf68dd1 | |||
| 2131ca3e3f | |||
| 3a3f201065 | |||
| ecafb5e1c0 | |||
| 17e33aa53f | |||
| 4436b69bf9 | |||
| 3bc9127c06 | |||
| 072cb24e5b | |||
| 3224faa2da | |||
| d067920392 | |||
| 9656f3f426 | |||
| f730fd958d | |||
| 2310292b35 | |||
| 0b6af0cb10 | |||
| b78ee14cbe | |||
| fdecf445ec | |||
| d5f260bd7e | |||
| 56c547d7e0 | |||
| 4396754cbd | |||
| 60c7c72036 | |||
| f81e8b6162 | |||
| e4ecd0d18a | |||
| 8bc2dc3102 | |||
| 55de231a37 | |||
| aab403fd9b |
@@ -31,7 +31,7 @@ jobs:
|
|||||||
build-mode: none
|
build-mode: none
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||||
|
|
||||||
- name: Set up pnpm package manager
|
- name: Set up pnpm package manager
|
||||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
|
|||||||
@@ -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
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||||
- name: Contribute List
|
- name: Contribute List
|
||||||
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
|
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||||
|
|
||||||
- name: Install Nix
|
- name: Install Nix
|
||||||
uses: cachix/install-nix-action@a6f7623b2e2401f485f1eead77ced45bd99b09b0 #v31
|
uses: cachix/install-nix-action@a6f7623b2e2401f485f1eead77ced45bd99b09b0 #v31
|
||||||
@@ -47,3 +47,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Run flake info app
|
- name: Run flake info app
|
||||||
run: nix run .#info
|
run: nix run .#info
|
||||||
|
|
||||||
|
# `nix flake show` above only evaluates the flake. This step actually
|
||||||
|
# compiles the app inside the Nix environment, which is what catches a
|
||||||
|
# missing build-time dependency — in particular libayatana-appindicator
|
||||||
|
# (required by libappindicator-sys for the Linux system tray). The build
|
||||||
|
# fails here if that dependency is dropped from the flake.
|
||||||
|
- name: Build the app via the flake
|
||||||
|
run: nix run .#build
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Issue Compliance Check
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
issues:
|
issues:
|
||||||
types: [opened, edited]
|
types: [opened]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -13,11 +13,16 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-compliance:
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
|
||||||
- name: Gather context
|
- name: Gather context
|
||||||
env:
|
env:
|
||||||
@@ -44,7 +49,7 @@ jobs:
|
|||||||
- A feature request gives no use case at all
|
- 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)
|
- 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
|
## Output schema
|
||||||
{
|
{
|
||||||
@@ -83,7 +88,7 @@ jobs:
|
|||||||
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/raw.txt
|
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/raw.txt
|
||||||
|
|
||||||
# Strip accidental markdown fences and parse. On parse failure, fall back
|
# 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
|
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
|
||||||
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
|
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
|
||||||
echo "::warning::Model returned non-JSON; treating as compliant"
|
echo "::warning::Model returned non-JSON; treating as compliant"
|
||||||
@@ -94,6 +99,7 @@ jobs:
|
|||||||
cat /tmp/result.json
|
cat /tmp/result.json
|
||||||
|
|
||||||
- name: Build comment
|
- name: Build comment
|
||||||
|
id: build
|
||||||
run: |
|
run: |
|
||||||
python3 - <<'EOF'
|
python3 - <<'EOF'
|
||||||
import json, os
|
import json, os
|
||||||
@@ -103,167 +109,25 @@ jobs:
|
|||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
if not compliant:
|
if not compliant:
|
||||||
parts.append('<!-- issue-compliance -->')
|
parts.append("This issue was automatically closed because it doesn't follow our [issue templates](../issues/new/choose).")
|
||||||
parts.append("This issue doesn't fully meet our [contributing guidelines](../blob/main/CONTRIBUTING.md).")
|
|
||||||
parts.append('')
|
parts.append('')
|
||||||
parts.append('**What needs to be fixed:**')
|
parts.append('**What was missing:**')
|
||||||
for reason in reasons:
|
for reason in reasons:
|
||||||
parts.append(f'- {reason}')
|
parts.append(f'- {reason}')
|
||||||
parts.append('')
|
parts.append('')
|
||||||
parts.append('Please edit this issue to address the above within **24 hours**, or it will be automatically closed.')
|
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.')
|
||||||
parts.append('')
|
|
||||||
parts.append('If you believe this was flagged incorrectly, please let a maintainer know.')
|
|
||||||
|
|
||||||
comment = '\n'.join(parts).strip()
|
comment = '\n'.join(parts).strip()
|
||||||
open('/tmp/comment.md', 'w').write(comment)
|
open('/tmp/comment.md', 'w').write(comment)
|
||||||
with open(os.environ['GITHUB_OUTPUT'], 'a') as fh:
|
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')
|
fh.write(f'non_compliant={"true" if not compliant else "false"}\n')
|
||||||
EOF
|
EOF
|
||||||
id: build
|
|
||||||
|
|
||||||
- name: Post comment
|
- name: Comment and close non-compliant issue
|
||||||
if: steps.build.outputs.has_comment == 'true'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
|
||||||
run: |
|
|
||||||
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
|
|
||||||
|
|
||||||
- name: Apply needs:compliance label
|
|
||||||
if: steps.build.outputs.non_compliant == 'true'
|
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:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||||
run: |
|
run: |
|
||||||
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||||
|
|
||||||
- name: Check if first-time contributor
|
- name: Check if first-time contributor
|
||||||
id: check-first-time
|
id: check-first-time
|
||||||
@@ -479,7 +479,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||||
|
|
||||||
- name: Check if first-time contributor
|
- name: Check if first-time contributor
|
||||||
id: check-first-time
|
id: check-first-time
|
||||||
@@ -617,10 +617,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||||
|
|
||||||
- name: Run opencode
|
- name: Run opencode
|
||||||
uses: anomalyco/opencode/github@d74d166acf40e51146f8547216913a4e787a4bc1 #v1.15.10
|
uses: anomalyco/opencode/github@76c631d198f9ff620e15468e45f3457d50481b57 #v1.16.2
|
||||||
env:
|
env:
|
||||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
run: git config --global core.autocrlf false
|
run: git config --global core.autocrlf false
|
||||||
|
|
||||||
- name: Checkout repository code
|
- name: Checkout repository code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||||
|
|
||||||
- name: Set up pnpm package manager
|
- name: Set up pnpm package manager
|
||||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
run: git config --global core.autocrlf false
|
run: git config --global core.autocrlf false
|
||||||
|
|
||||||
- name: Checkout repository code
|
- name: Checkout repository code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||||
|
|
||||||
- name: Set up pnpm package manager
|
- name: Set up pnpm package manager
|
||||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
@@ -88,7 +88,6 @@ jobs:
|
|||||||
working-directory: ./src-tauri
|
working-directory: ./src-tauri
|
||||||
run: |
|
run: |
|
||||||
cargo build --bin donut-proxy --release
|
cargo build --bin donut-proxy --release
|
||||||
cargo build --bin donut-daemon --release
|
|
||||||
|
|
||||||
- name: Copy sidecar binaries to Tauri binaries
|
- name: Copy sidecar binaries to Tauri binaries
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -97,12 +96,9 @@ jobs:
|
|||||||
HOST_TARGET="${{ steps.host_target.outputs.target }}"
|
HOST_TARGET="${{ steps.host_target.outputs.target }}"
|
||||||
if [[ "$HOST_TARGET" == *"windows"* ]]; then
|
if [[ "$HOST_TARGET" == *"windows"* ]]; then
|
||||||
cp src-tauri/target/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${HOST_TARGET}.exe
|
cp src-tauri/target/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${HOST_TARGET}.exe
|
||||||
cp src-tauri/target/release/donut-daemon.exe src-tauri/binaries/donut-daemon-${HOST_TARGET}.exe
|
|
||||||
else
|
else
|
||||||
cp src-tauri/target/release/donut-proxy src-tauri/binaries/donut-proxy-${HOST_TARGET}
|
cp src-tauri/target/release/donut-proxy src-tauri/binaries/donut-proxy-${HOST_TARGET}
|
||||||
cp src-tauri/target/release/donut-daemon src-tauri/binaries/donut-daemon-${HOST_TARGET}
|
|
||||||
chmod +x src-tauri/binaries/donut-proxy-${HOST_TARGET}
|
chmod +x src-tauri/binaries/donut-proxy-${HOST_TARGET}
|
||||||
chmod +x src-tauri/binaries/donut-daemon-${HOST_TARGET}
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Run rustfmt check
|
- name: Run rustfmt check
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
github.event.workflow_run.conclusion == 'success')
|
github.event.workflow_run.conclusion == 'success')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -126,7 +126,7 @@ jobs:
|
|||||||
- name: Generate summary with AI
|
- name: Generate summary with AI
|
||||||
id: ai
|
id: ai
|
||||||
if: steps.gate.outputs.skip != 'true'
|
if: steps.gate.outputs.skip != 'true'
|
||||||
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
|
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||||
with:
|
with:
|
||||||
prompt-file: .github/prompts/telegram-release-summary.prompt.yml
|
prompt-file: .github/prompts/telegram-release-summary.prompt.yml
|
||||||
input: |
|
input: |
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ jobs:
|
|||||||
github.event.workflow_run.conclusion == 'success')
|
github.event.workflow_run.conclusion == 'success')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||||
|
|
||||||
- name: Determine release tag
|
- name: Determine release tag
|
||||||
id: tag
|
id: tag
|
||||||
env:
|
env:
|
||||||
@@ -40,182 +43,35 @@ jobs:
|
|||||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
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
|
- name: Install tools
|
||||||
run: |
|
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 update
|
||||||
sudo apt-get install -y dpkg-dev createrepo-c python3-pip
|
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
|
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"
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
aws --version
|
|
||||||
|
|
||||||
- name: Download packages from GitHub release
|
- name: Publish DEB & RPM repositories to R2
|
||||||
env:
|
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 }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAG: ${{ steps.tag.outputs.tag }}
|
|
||||||
run: |
|
run: |
|
||||||
mkdir -p /tmp/packages
|
# GitHub injects secrets verbatim. If a value was pasted with
|
||||||
gh release download "$TAG" \
|
# surrounding quotes or a trailing newline — the local .env wraps all
|
||||||
--repo "${{ github.repository }}" \
|
# four R2_* values in double quotes — it reaches the script malformed:
|
||||||
--pattern "*.deb" \
|
# e.g. an endpoint of https://"host" yields
|
||||||
--dir /tmp/packages
|
# `Could not connect to the endpoint URL`, and a quoted key yields
|
||||||
gh release download "$TAG" \
|
# `Unauthorized`. The local run is unaffected because publish-repo.sh
|
||||||
--repo "${{ github.repository }}" \
|
# sources .env through bash, which strips the quotes; CI has no .env,
|
||||||
--pattern "*.rpm" \
|
# so strip here. No-op when the secrets are already clean. The script
|
||||||
--dir /tmp/packages
|
# itself is intentionally left untouched.
|
||||||
echo "Downloaded packages:"
|
strip() { printf '%s' "$1" | tr -d '\r\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/"; }
|
||||||
ls -lh /tmp/packages/
|
export R2_ACCESS_KEY_ID="$(strip "$R2_ACCESS_KEY_ID")"
|
||||||
|
export R2_SECRET_ACCESS_KEY="$(strip "$R2_SECRET_ACCESS_KEY")"
|
||||||
- name: Build DEB repository
|
export R2_ENDPOINT_URL="$(strip "$R2_ENDPOINT_URL")"
|
||||||
env:
|
export R2_BUCKET_NAME="$(strip "$R2_BUCKET_NAME")"
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
bash scripts/publish-repo.sh "${{ steps.tag.outputs.tag }}"
|
||||||
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)"
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ jobs:
|
|||||||
- name: Generate release notes with AI
|
- name: Generate release notes with AI
|
||||||
id: generate-notes
|
id: generate-notes
|
||||||
if: steps.get-release.outputs.is-prerelease == 'false'
|
if: steps.get-release.outputs.is-prerelease == 'false'
|
||||||
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
|
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||||
with:
|
with:
|
||||||
prompt-file: .github/prompts/release-notes.prompt.yml
|
prompt-file: .github/prompts/release-notes.prompt.yml
|
||||||
input: |
|
input: |
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ jobs:
|
|||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
@@ -162,7 +162,6 @@ jobs:
|
|||||||
working-directory: ./src-tauri
|
working-directory: ./src-tauri
|
||||||
run: |
|
run: |
|
||||||
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
|
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
|
||||||
cargo build --bin donut-daemon --target ${{ matrix.target }} --release
|
|
||||||
|
|
||||||
- name: Copy sidecar binaries to Tauri binaries
|
- name: Copy sidecar binaries to Tauri binaries
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -170,12 +169,9 @@ jobs:
|
|||||||
mkdir -p src-tauri/binaries
|
mkdir -p src-tauri/binaries
|
||||||
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
|
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
|
||||||
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${{ matrix.target }}.exe
|
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${{ matrix.target }}.exe
|
||||||
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe src-tauri/binaries/donut-daemon-${{ matrix.target }}.exe
|
|
||||||
else
|
else
|
||||||
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy src-tauri/binaries/donut-proxy-${{ matrix.target }}
|
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy src-tauri/binaries/donut-proxy-${{ matrix.target }}
|
||||||
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon src-tauri/binaries/donut-daemon-${{ matrix.target }}
|
|
||||||
chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }}
|
chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }}
|
||||||
chmod +x src-tauri/binaries/donut-daemon-${{ matrix.target }}
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Import Apple certificate
|
- name: Import Apple certificate
|
||||||
@@ -250,7 +246,12 @@ jobs:
|
|||||||
|
|
||||||
# Copy sidecar binaries
|
# Copy sidecar binaries
|
||||||
cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/"
|
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
|
# Copy WebView2Loader if present
|
||||||
if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
|
if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
|
||||||
@@ -287,7 +288,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -453,7 +454,7 @@ jobs:
|
|||||||
needs: [release, changelog]
|
needs: [release, changelog]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -551,7 +552,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ jobs:
|
|||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
@@ -161,7 +161,6 @@ jobs:
|
|||||||
working-directory: ./src-tauri
|
working-directory: ./src-tauri
|
||||||
run: |
|
run: |
|
||||||
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
|
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
|
||||||
cargo build --bin donut-daemon --target ${{ matrix.target }} --release
|
|
||||||
|
|
||||||
- name: Copy sidecar binaries to Tauri binaries
|
- name: Copy sidecar binaries to Tauri binaries
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -169,12 +168,9 @@ jobs:
|
|||||||
mkdir -p src-tauri/binaries
|
mkdir -p src-tauri/binaries
|
||||||
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
|
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
|
||||||
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${{ matrix.target }}.exe
|
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${{ matrix.target }}.exe
|
||||||
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe src-tauri/binaries/donut-daemon-${{ matrix.target }}.exe
|
|
||||||
else
|
else
|
||||||
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy src-tauri/binaries/donut-proxy-${{ matrix.target }}
|
cp src-tauri/target/${{ matrix.target }}/release/donut-proxy src-tauri/binaries/donut-proxy-${{ matrix.target }}
|
||||||
cp src-tauri/target/${{ matrix.target }}/release/donut-daemon src-tauri/binaries/donut-daemon-${{ matrix.target }}
|
|
||||||
chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }}
|
chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }}
|
||||||
chmod +x src-tauri/binaries/donut-daemon-${{ matrix.target }}
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Import Apple certificate
|
- name: Import Apple certificate
|
||||||
@@ -251,7 +247,12 @@ jobs:
|
|||||||
|
|
||||||
cp "src-tauri/target/${{ matrix.target }}/release/donutbrowser.exe" "$PORTABLE_DIR/Donut.exe"
|
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-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
|
if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
|
||||||
cp "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" "$PORTABLE_DIR/"
|
cp "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" "$PORTABLE_DIR/"
|
||||||
@@ -283,7 +284,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||||
|
|
||||||
- name: Generate nightly tag
|
- name: Generate nightly tag
|
||||||
id: tag
|
id: tag
|
||||||
|
|||||||
@@ -21,6 +21,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Actions Repository
|
- name: Checkout Actions Repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||||
- name: Spell Check Repo
|
- name: Spell Check Repo
|
||||||
uses: crate-ci/typos@aca895bf05aec0cb7dffa6f94495e923224d9f17 #v1.46.2
|
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 #v1.47.2
|
||||||
|
|||||||
@@ -22,3 +22,6 @@ jobs:
|
|||||||
stale-pr-label: "stale"
|
stale-pr-label: "stale"
|
||||||
days-before-stale: 30
|
days-before-stale: 30
|
||||||
days-before-close: 7
|
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,7 +32,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6.0.2
|
uses: actions/checkout@v6.0.3
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6.0.2
|
uses: actions/checkout@v6.0.3
|
||||||
|
|
||||||
- name: Start MinIO
|
- name: Start MinIO
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ donutbrowser/
|
|||||||
│ ├── app/ # App router (page.tsx, layout.tsx)
|
│ ├── app/ # App router (page.tsx, layout.tsx)
|
||||||
│ ├── components/ # 50+ React components (dialogs, tables, UI)
|
│ ├── components/ # 50+ React components (dialogs, tables, UI)
|
||||||
│ ├── hooks/ # Event-driven React hooks
|
│ ├── 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)
|
│ ├── lib/ # Utilities (themes, toast, browser-utils)
|
||||||
│ └── types.ts # Shared TypeScript interfaces
|
│ └── types.ts # Shared TypeScript interfaces
|
||||||
├── src-tauri/ # Rust backend (Tauri)
|
├── src-tauri/ # Rust backend (Tauri)
|
||||||
@@ -27,9 +27,7 @@ donutbrowser/
|
|||||||
│ │ ├── mcp_server.rs # MCP protocol server
|
│ │ ├── mcp_server.rs # MCP protocol server
|
||||||
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
|
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
|
||||||
│ │ ├── vpn/ # WireGuard tunnels
|
│ │ ├── vpn/ # WireGuard tunnels
|
||||||
│ │ ├── camoufox/ # Camoufox fingerprint engine (Bayesian network)
|
|
||||||
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
|
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
|
||||||
│ │ ├── camoufox_manager.rs # Camoufox (Firefox) browser management
|
|
||||||
│ │ ├── downloader.rs # Browser binary downloader
|
│ │ ├── downloader.rs # Browser binary downloader
|
||||||
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
|
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
|
||||||
│ │ ├── settings_manager.rs # App settings persistence
|
│ │ ├── settings_manager.rs # App settings persistence
|
||||||
@@ -56,6 +54,15 @@ donutbrowser/
|
|||||||
- The full `pnpm test` output dumps every test name (≈400+ lines) which burns context for no signal. Filter:
|
- The full `pnpm test` output dumps every test name (≈400+ lines) which burns context for no signal. Filter:
|
||||||
`pnpm test 2>&1 | grep -E "test result|panicked|FAILED"` — four "test result: ok" lines means everything passed.
|
`pnpm test 2>&1 | grep -E "test result|panicked|FAILED"` — four "test result: ok" lines means everything passed.
|
||||||
|
|
||||||
|
## Logs (when debugging a running app)
|
||||||
|
|
||||||
|
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 `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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Code Quality
|
## Code Quality
|
||||||
|
|
||||||
- Don't leave comments that don't add value
|
- Don't leave comments that don't add value
|
||||||
@@ -66,12 +73,12 @@ donutbrowser/
|
|||||||
|
|
||||||
- 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()`.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- **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.
|
- 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)
|
## Backend error codes (mandatory)
|
||||||
|
|
||||||
@@ -85,7 +92,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`.
|
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", …)`.
|
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.
|
Raw error strings reach the user untranslated; that's the bug pattern this rule blocks.
|
||||||
|
|
||||||
@@ -138,7 +145,7 @@ Reference implementations: `proxy-management-dialog.tsx`, `extension-management-
|
|||||||
|
|
||||||
All app-wide shortcuts live in `src/lib/shortcuts.ts`:
|
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.
|
- `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.
|
- `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.
|
- `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.
|
||||||
@@ -148,7 +155,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.
|
1. Append to `SHORTCUTS` in `src/lib/shortcuts.ts`. Add the `ShortcutId` variant.
|
||||||
2. Add a `case "yourId":` in `runShortcut` in `page.tsx`.
|
2. Add a `case "yourId":` in `runShortcut` in `page.tsx`.
|
||||||
3. Add the icon mapping in `src/components/command-palette.tsx::ICONS`.
|
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.
|
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.
|
||||||
|
|
||||||
@@ -206,6 +213,57 @@ The `.github/workflows/publish-repos.yml` workflow runs automatically after stab
|
|||||||
|
|
||||||
Required env vars / secrets: `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ENDPOINT_URL`, `R2_BUCKET_NAME`.
|
Required env vars / secrets: `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ENDPOINT_URL`, `R2_BUCKET_NAME`.
|
||||||
|
|
||||||
|
## Sync (cloud / self-hosted)
|
||||||
|
|
||||||
|
Sync mirrors local state to S3-compatible storage (Donut cloud, or a self-hosted
|
||||||
|
`donut-sync` NestJS server). Two distinct mechanisms live in `src-tauri/src/sync/`:
|
||||||
|
|
||||||
|
- **Profile browser files** (the Chromium/Firefox profile directory): a
|
||||||
|
**content-hash manifest** (`manifest.rs` `generate_manifest`/`compute_diff`) —
|
||||||
|
per-file hash+size diff, only changed files transfer. `sync_profile` in
|
||||||
|
`engine.rs`.
|
||||||
|
- **Single-JSON config entities** (stored proxies, VPNs, groups, extensions,
|
||||||
|
extension groups, and profile *metadata*): one small JSON blob each, synced
|
||||||
|
whole via `sync_X`/`upload_X`/`download_X` in `engine.rs`.
|
||||||
|
|
||||||
|
### Conflict resolution — one rule everywhere: `updated_at` last-write-wins
|
||||||
|
|
||||||
|
Every config entity carries `updated_at: Option<u64>` (unix seconds;
|
||||||
|
`extension_manager` uses a non-Optional `u64`). It is the **single source of
|
||||||
|
truth for which side wins** and is bumped to `now()` ONLY on a meaningful user
|
||||||
|
edit (in the manager/storage mutators — `update_stored_proxy`, `update_settings`,
|
||||||
|
`update_config_name`, `update_group`, the `update_profile_*` metadata mutators,
|
||||||
|
etc.), NEVER by sync bookkeeping. Use `crate::proxy_manager::now_secs()`.
|
||||||
|
|
||||||
|
`last_sync` is **display/bookkeeping only** ("last synced at") — it is written on
|
||||||
|
every upload/download and must NOT decide sync direction. (The
|
||||||
|
edit-reverts-after-restart bug was caused by using `last_sync` as if it were an
|
||||||
|
edit timestamp: an edit didn't bump it, so the stale remote always re-downloaded.)
|
||||||
|
|
||||||
|
Reconcile (`engine.rs::remote_updated_at` + each `sync_X`):
|
||||||
|
1. `stat` (HEAD) the remote object. Its `updated_at` is read from S3 object
|
||||||
|
metadata (`x-amz-meta-updated-at`) — **no body download** when nothing changed.
|
||||||
|
2. Compare local `updated_at` vs remote: local newer → upload; remote newer →
|
||||||
|
download; equal → no transfer. Legacy objects with no timestamp resolve to 0,
|
||||||
|
so any real edit wins.
|
||||||
|
3. **Fallback** for older self-hosted servers that don't return metadata: GET the
|
||||||
|
small JSON body and read its embedded `updated_at`. Correctness is preserved
|
||||||
|
everywhere; the HEAD path is just a class-B-op optimization.
|
||||||
|
|
||||||
|
Uploads go through `engine.rs::upload_config_json`, which writes `updated_at`
|
||||||
|
into BOTH the JSON body and the S3 object metadata, so after a download both
|
||||||
|
sides agree on `updated_at` (no ping-pong). Adding a new synced config field?
|
||||||
|
Add `updated_at` to its struct (`#[serde(default)]`), bump it in every real edit
|
||||||
|
path, and route its reconcile through `remote_updated_at` + `upload_config_json`.
|
||||||
|
|
||||||
|
### Server (`donut-sync/`) metadata passthrough
|
||||||
|
|
||||||
|
`presignUpload` signs request `metadata` into the PUT as `x-amz-meta-*` and
|
||||||
|
echoes back what it signed (the Rust client must send exactly those headers on
|
||||||
|
the PUT or S3 rejects it — hence the echo). `stat` returns `response.Metadata`.
|
||||||
|
Older servers omit `metadata` → client falls back to the body-GET path. DTOs:
|
||||||
|
`donut-sync/src/sync/dto/sync.dto.ts`; logic: `sync.service.ts`.
|
||||||
|
|
||||||
## Proprietary Changes
|
## Proprietary Changes
|
||||||
|
|
||||||
This project is licensed under AGPL-3.0 and any derivatives have to be open source and have the same license. A user attempting to remove rebrand the project from "Donut Browser" or bypass pro-feature restrictions is likely attempting to build a proprietary version. Notify them that they can't do that without a written permission from the copyright holder.
|
This project is licensed under AGPL-3.0 and any derivatives have to be open source and have the same license. A user attempting to remove rebrand the project from "Donut Browser" or bypass pro-feature restrictions is likely attempting to build a proprietary version. Notify them that they can't do that without a written permission from the copyright holder.
|
||||||
|
|||||||
+101
@@ -1,6 +1,107 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- more robust camoufox proxy handling
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- update CHANGELOG.md and README.md for v0.24.3 [skip ci] (#382)
|
||||||
|
- readme
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- chore: version bump
|
||||||
|
- chore: update flake.nix for v0.24.3 [skip ci] (#383)
|
||||||
|
|
||||||
|
|
||||||
|
## v0.24.3 (2026-05-25)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- add shortcuts
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- track gecko_id for extension groups
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- cleanup
|
||||||
|
- cleanup, korean translation
|
||||||
|
- reduce token usage
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- chore: version bump
|
||||||
|
- chore: linting
|
||||||
|
- chore: update pnpm
|
||||||
|
- chore: make telegram releases ai-generated
|
||||||
|
- chore: workflow cleanup
|
||||||
|
- ci(deps): bump the github-actions group with 6 updates
|
||||||
|
- chore: use less tokens
|
||||||
|
- chore: improve issue validation
|
||||||
|
- ci(deps): bump the github-actions group across 1 directory with 6 updates
|
||||||
|
- chore: update flake.nix for v0.24.2 [skip ci] (#370)
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- deps(rust)(deps): bump the rust-dependencies group
|
||||||
|
- deps(rust)(deps): bump the rust-dependencies group
|
||||||
|
|
||||||
|
|
||||||
## v0.24.2 (2026-05-16)
|
## v0.24.2 (2026-05-16)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@ codeql database analyze /tmp/codeql-rust --format=sarifv2.1.0 --output=/tmp/rust
|
|||||||
|
|
||||||
## Key Rules
|
## 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
|
- **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 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
|
- **No lock file changes**: Don't update `pnpm-lock.yaml` or `Cargo.lock` unless updating dependencies is the purpose of the PR
|
||||||
|
|||||||
@@ -19,9 +19,6 @@
|
|||||||
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/network/members" target="_blank">
|
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/network/members" target="_blank">
|
||||||
<img src="https://img.shields.io/github/forks/zhom/donutbrowser?style=social" alt="GitHub forks">
|
<img src="https://img.shields.io/github/forks/zhom/donutbrowser?style=social" alt="GitHub forks">
|
||||||
</a>
|
</a>
|
||||||
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/releases" target="_blank">
|
|
||||||
<img src="https://img.shields.io/github/downloads/zhom/donutbrowser/total" alt="Downloads">
|
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<img alt="Donut Browser Preview" src="assets/donut-preview.png" />
|
<img alt="Donut Browser Preview" src="assets/donut-preview.png" />
|
||||||
@@ -29,7 +26,8 @@
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data
|
- **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
|
- **Anti-detect Chromium engine** — powered by [Wayfern](https://wayfern.com), with advanced fingerprint spoofing
|
||||||
|
- **DNS AdBlocker** - block ads, trackers, and other unwanted content with per-profile DNS blocking
|
||||||
- **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
|
- **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
|
||||||
- **VPN support** — WireGuard configs per profile
|
- **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
|
- **Local API & MCP** — REST API and [Model Context Protocol](https://modelcontextprotocol.io) server for integration with Claude, automation tools, and custom workflows
|
||||||
@@ -48,7 +46,7 @@
|
|||||||
|
|
||||||
| | Apple Silicon | Intel |
|
| | Apple Silicon | Intel |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_x64.dmg) |
|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_x64.dmg) |
|
||||||
|
|
||||||
Or install via Homebrew:
|
Or install via Homebrew:
|
||||||
|
|
||||||
@@ -58,15 +56,15 @@ brew install --cask donut
|
|||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_x64-portable.zip)
|
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_x64-portable.zip)
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
| Format | x86_64 | ARM64 |
|
| Format | x86_64 | ARM64 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_arm64.deb) |
|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_arm64.deb) |
|
||||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut-0.24.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut-0.24.2-1.aarch64.rpm) |
|
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut-0.25.3-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut-0.25.3-1.aarch64.rpm) |
|
||||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_aarch64.AppImage) |
|
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_aarch64.AppImage) |
|
||||||
<!-- install-links-end -->
|
<!-- install-links-end -->
|
||||||
|
|
||||||
Or install via package manager:
|
Or install via package manager:
|
||||||
@@ -137,6 +135,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|||||||
<sub><b>Hassiy</b></sub>
|
<sub><b>Hassiy</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/webees">
|
||||||
|
<img src="https://avatars.githubusercontent.com/u/5155291?v=4" width="100;" alt="webees"/>
|
||||||
|
<br />
|
||||||
|
<sub><b>JockLee</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/yb403">
|
<a href="https://github.com/yb403">
|
||||||
<img src="https://avatars.githubusercontent.com/u/87396571?v=4" width="100;" alt="yb403"/>
|
<img src="https://avatars.githubusercontent.com/u/87396571?v=4" width="100;" alt="yb403"/>
|
||||||
@@ -144,6 +149,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|||||||
<sub><b>yb403</b></sub>
|
<sub><b>yb403</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</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">
|
<td align="center">
|
||||||
<a href="https://github.com/drunkod">
|
<a href="https://github.com/drunkod">
|
||||||
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="drunkod"/>
|
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="drunkod"/>
|
||||||
@@ -151,6 +163,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|||||||
<sub><b>drunkod</b></sub>
|
<sub><b>drunkod</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/JorySeverijnse">
|
<a href="https://github.com/JorySeverijnse">
|
||||||
<img src="https://avatars.githubusercontent.com/u/117462355?v=4" width="100;" alt="JorySeverijnse"/>
|
<img src="https://avatars.githubusercontent.com/u/117462355?v=4" width="100;" alt="JorySeverijnse"/>
|
||||||
|
|||||||
+3
-1
@@ -3,7 +3,9 @@ extend-exclude = [
|
|||||||
"src-tauri/src/camoufox/data/*.json",
|
"src-tauri/src/camoufox/data/*.json",
|
||||||
"src-tauri/src/camoufox/data/*.xml",
|
"src-tauri/src/camoufox/data/*.xml",
|
||||||
"src/i18n/locales/*.json",
|
"src/i18n/locales/*.json",
|
||||||
"src-tauri/build.rs",
|
# Auto-generated from commit subjects by release.yml; typos here originate
|
||||||
|
# in commit messages, which are immutable, so don't spell-check it.
|
||||||
|
"CHANGELOG.md",
|
||||||
]
|
]
|
||||||
|
|
||||||
[default.extend-words]
|
[default.extend-words]
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 623 KiB After Width: | Height: | Size: 508 KiB |
@@ -1,3 +1,4 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
type CanActivate,
|
type CanActivate,
|
||||||
type ExecutionContext,
|
type ExecutionContext,
|
||||||
@@ -10,6 +11,13 @@ import type { Request } from "express";
|
|||||||
import * as jwt from "jsonwebtoken";
|
import * as jwt from "jsonwebtoken";
|
||||||
import type { UserContext } from "./user-context.interface.js";
|
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()
|
@Injectable()
|
||||||
export class AuthGuard implements CanActivate {
|
export class AuthGuard implements CanActivate {
|
||||||
private readonly logger = new Logger(AuthGuard.name);
|
private readonly logger = new Logger(AuthGuard.name);
|
||||||
@@ -37,7 +45,7 @@ export class AuthGuard implements CanActivate {
|
|||||||
|
|
||||||
// Try SYNC_TOKEN first (self-hosted mode)
|
// Try SYNC_TOKEN first (self-hosted mode)
|
||||||
const expectedToken = this.configService.get<string>("SYNC_TOKEN");
|
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 = {
|
(request as unknown as Record<string, unknown>).user = {
|
||||||
mode: "self-hosted",
|
mode: "self-hosted",
|
||||||
prefix: "",
|
prefix: "",
|
||||||
@@ -55,10 +63,29 @@ export class AuthGuard implements CanActivate {
|
|||||||
algorithms: ["RS256"],
|
algorithms: ["RS256"],
|
||||||
}) as jwt.JwtPayload;
|
}) 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 = {
|
(request as unknown as Record<string, unknown>).user = {
|
||||||
mode: "cloud",
|
mode: "cloud",
|
||||||
prefix: decoded.prefix || `users/${decoded.sub}/`,
|
prefix,
|
||||||
teamPrefix: decoded.teamPrefix || null,
|
teamPrefix,
|
||||||
profileLimit: decoded.profileLimit || 0,
|
profileLimit: decoded.profileLimit || 0,
|
||||||
teamProfileLimit: decoded.teamProfileLimit || 0,
|
teamProfileLimit: decoded.teamProfileLimit || 0,
|
||||||
} satisfies UserContext;
|
} satisfies UserContext;
|
||||||
|
|||||||
@@ -6,17 +6,25 @@ export class StatResponseDto {
|
|||||||
exists: boolean;
|
exists: boolean;
|
||||||
lastModified?: string;
|
lastModified?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
|
// User-defined S3 object metadata (lowercased keys, no `x-amz-meta-` prefix).
|
||||||
|
// Carries `updated-at` for sync conflict resolution via HEAD (no body GET).
|
||||||
|
metadata?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PresignUploadRequestDto {
|
export class PresignUploadRequestDto {
|
||||||
key: string;
|
key: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
expiresIn?: number;
|
expiresIn?: number;
|
||||||
|
// Object metadata to sign into the presigned PUT as `x-amz-meta-*`.
|
||||||
|
metadata?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PresignUploadResponseDto {
|
export class PresignUploadResponseDto {
|
||||||
url: string;
|
url: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
|
// Metadata the server actually signed; the client must echo it as
|
||||||
|
// `x-amz-meta-*` headers on the PUT (older clients/servers omit it).
|
||||||
|
metadata?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PresignDownloadRequestDto {
|
export class PresignDownloadRequestDto {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@@ -9,6 +10,13 @@ import {
|
|||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { SyncService } from "./sync.service.js";
|
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")
|
@Controller("v1/internal")
|
||||||
export class InternalController {
|
export class InternalController {
|
||||||
private readonly internalKey: string | undefined;
|
private readonly internalKey: string | undefined;
|
||||||
@@ -26,7 +34,7 @@ export class InternalController {
|
|||||||
@Headers("x-internal-key") key: string,
|
@Headers("x-internal-key") key: string,
|
||||||
@Body() body: { userId: string; maxProfiles: number },
|
@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");
|
throw new UnauthorizedException("Invalid internal key");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,29 @@ import type {
|
|||||||
*/
|
*/
|
||||||
const MANIFEST_KEY = ".donut-sync-manifest";
|
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()
|
@Injectable()
|
||||||
export class SyncService implements OnModuleInit {
|
export class SyncService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(SyncService.name);
|
private readonly logger = new Logger(SyncService.name);
|
||||||
@@ -256,6 +279,10 @@ export class SyncService implements OnModuleInit {
|
|||||||
exists: true,
|
exists: true,
|
||||||
lastModified: response.LastModified?.toISOString(),
|
lastModified: response.LastModified?.toISOString(),
|
||||||
size: response.ContentLength,
|
size: response.ContentLength,
|
||||||
|
// S3 returns user metadata with lowercased keys and no `x-amz-meta-`
|
||||||
|
// prefix. Clients read `updated-at` from here to resolve sync conflicts
|
||||||
|
// without downloading the object body.
|
||||||
|
metadata: response.Metadata,
|
||||||
};
|
};
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (
|
if (
|
||||||
@@ -282,13 +309,19 @@ export class SyncService implements OnModuleInit {
|
|||||||
await this.checkProfileLimit(ctx);
|
await this.checkProfileLimit(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresIn = dto.expiresIn || 3600;
|
const expiresIn = clampExpiresIn(dto.expiresIn);
|
||||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
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({
|
const command = new PutCmd({
|
||||||
Bucket: this.bucket,
|
Bucket: this.bucket,
|
||||||
Key: key,
|
Key: key,
|
||||||
ContentType: dto.contentType || "application/octet-stream",
|
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: metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
|
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
|
||||||
@@ -306,6 +339,9 @@ export class SyncService implements OnModuleInit {
|
|||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
expiresAt: expiresAt.toISOString(),
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +352,7 @@ export class SyncService implements OnModuleInit {
|
|||||||
const key = this.scopeKey(ctx, dto.key);
|
const key = this.scopeKey(ctx, dto.key);
|
||||||
this.validateKeyAccess(ctx, key);
|
this.validateKeyAccess(ctx, key);
|
||||||
|
|
||||||
const expiresIn = dto.expiresIn || 3600;
|
const expiresIn = clampExpiresIn(dto.expiresIn);
|
||||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||||
|
|
||||||
const command = new GetObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
@@ -431,7 +467,7 @@ export class SyncService implements OnModuleInit {
|
|||||||
await this.checkProfileLimit(ctx);
|
await this.checkProfileLimit(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresIn = dto.expiresIn || 3600;
|
const expiresIn = clampExpiresIn(dto.expiresIn);
|
||||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||||
|
|
||||||
const items = await Promise.all(
|
const items = await Promise.all(
|
||||||
@@ -484,7 +520,7 @@ export class SyncService implements OnModuleInit {
|
|||||||
dto: PresignDownloadBatchRequestDto,
|
dto: PresignDownloadBatchRequestDto,
|
||||||
ctx: UserContext,
|
ctx: UserContext,
|
||||||
): Promise<PresignDownloadBatchResponseDto> {
|
): Promise<PresignDownloadBatchResponseDto> {
|
||||||
const expiresIn = dto.expiresIn || 3600;
|
const expiresIn = clampExpiresIn(dto.expiresIn);
|
||||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||||
|
|
||||||
const items = await Promise.all(
|
const items = await Promise.all(
|
||||||
|
|||||||
Generated
+3
-3
@@ -20,11 +20,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1767767207,
|
"lastModified": 1779560665,
|
||||||
"narHash": "sha256-Mj3d3PfwltLmukFal5i3fFt27L6NiKXdBezC1EBuZs4=",
|
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "5912c1772a44e31bf1c63c0390b90501e5026886",
|
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
libsoup_3
|
libsoup_3
|
||||||
glib
|
glib
|
||||||
gtk3
|
gtk3
|
||||||
|
libayatana-appindicator
|
||||||
cairo
|
cairo
|
||||||
gdk-pixbuf
|
gdk-pixbuf
|
||||||
pango
|
pango
|
||||||
@@ -84,6 +85,7 @@
|
|||||||
pkgs.gdk-pixbuf
|
pkgs.gdk-pixbuf
|
||||||
pkgs.glib
|
pkgs.glib
|
||||||
pkgs.gtk3
|
pkgs.gtk3
|
||||||
|
pkgs.libayatana-appindicator
|
||||||
pkgs.libsoup_3
|
pkgs.libsoup_3
|
||||||
pkgs.libxkbcommon
|
pkgs.libxkbcommon
|
||||||
pkgs.openssl
|
pkgs.openssl
|
||||||
@@ -94,17 +96,17 @@
|
|||||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||||
);
|
);
|
||||||
releaseVersion = "0.24.2";
|
releaseVersion = "0.25.3";
|
||||||
releaseAppImage =
|
releaseAppImage =
|
||||||
if system == "x86_64-linux" then
|
if system == "x86_64-linux" then
|
||||||
pkgs.fetchurl {
|
pkgs.fetchurl {
|
||||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_amd64.AppImage";
|
url = "https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_amd64.AppImage";
|
||||||
hash = "sha256-140PSB/1BLGUB4sI/RgfYe7uUjwRFWXtdSnUZz6Wr0U=";
|
hash = "sha256-GB+HMfMQuZj0YYibiyCD64u6o943anSI/1jyD36YJq4=";
|
||||||
}
|
}
|
||||||
else if system == "aarch64-linux" then
|
else if system == "aarch64-linux" then
|
||||||
pkgs.fetchurl {
|
pkgs.fetchurl {
|
||||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.2/Donut_0.24.2_aarch64.AppImage";
|
url = "https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_aarch64.AppImage";
|
||||||
hash = "sha256-QPGV6XO0ugPAJSbPJrVwDsEb9lw3dcL6IdU17UCYH4E=";
|
hash = "sha256-IKpz8AI3uM4+VxiF+8fwhj/mLn0KZW1KQMo3lGCTO8g=";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
null;
|
null;
|
||||||
|
|||||||
+6
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "donutbrowser",
|
"name": "donutbrowser",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"version": "0.24.3",
|
"version": "0.26.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 12341",
|
"dev": "next dev --turbopack -p 12341",
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-portal": "^1.1.10",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
@@ -54,16 +55,19 @@
|
|||||||
"@tauri-apps/plugin-log": "^2.8.0",
|
"@tauri-apps/plugin-log": "^2.8.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||||
"ahooks": "^3.9.7",
|
"ahooks": "^3.9.7",
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"color": "^5.0.3",
|
"color": "^5.0.3",
|
||||||
"flag-icons": "^7.5.0",
|
"flag-icons": "^7.5.0",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
"i18next": "^26.1.0",
|
"i18next": "^26.1.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
"next": "^16.2.6",
|
"next": "^16.2.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"onborda": "^1.2.5",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
@@ -78,6 +82,7 @@
|
|||||||
"@biomejs/biome": "2.4.15",
|
"@biomejs/biome": "2.4.15",
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"@tauri-apps/cli": "~2.11.1",
|
"@tauri-apps/cli": "~2.11.1",
|
||||||
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/color": "^4.2.1",
|
"@types/color": "^4.2.1",
|
||||||
"@types/node": "^25.7.0",
|
"@types/node": "^25.7.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
|
|||||||
Generated
+65
@@ -33,6 +33,9 @@ importers:
|
|||||||
'@radix-ui/react-popover':
|
'@radix-ui/react-popover':
|
||||||
specifier: ^1.1.15
|
specifier: ^1.1.15
|
||||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
'@radix-ui/react-portal':
|
||||||
|
specifier: ^1.1.10
|
||||||
|
version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
'@radix-ui/react-progress':
|
'@radix-ui/react-progress':
|
||||||
specifier: ^1.1.8
|
specifier: ^1.1.8
|
||||||
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
@@ -84,6 +87,9 @@ importers:
|
|||||||
ahooks:
|
ahooks:
|
||||||
specifier: ^3.9.7
|
specifier: ^3.9.7
|
||||||
version: 3.9.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 3.9.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
canvas-confetti:
|
||||||
|
specifier: ^1.9.4
|
||||||
|
version: 1.9.4
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -99,6 +105,9 @@ importers:
|
|||||||
flag-icons:
|
flag-icons:
|
||||||
specifier: ^7.5.0
|
specifier: ^7.5.0
|
||||||
version: 7.5.0
|
version: 7.5.0
|
||||||
|
framer-motion:
|
||||||
|
specifier: ^12.38.0
|
||||||
|
version: 12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^26.1.0
|
specifier: ^26.1.0
|
||||||
version: 26.1.0(typescript@6.0.3)
|
version: 26.1.0(typescript@6.0.3)
|
||||||
@@ -114,6 +123,9 @@ importers:
|
|||||||
next-themes:
|
next-themes:
|
||||||
specifier: ^0.4.6
|
specifier: ^0.4.6
|
||||||
version: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
onborda:
|
||||||
|
specifier: ^1.2.5
|
||||||
|
version: 1.2.5(@radix-ui/react-portal@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(framer-motion@12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
radix-ui:
|
radix-ui:
|
||||||
specifier: ^1.4.3
|
specifier: ^1.4.3
|
||||||
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
@@ -151,6 +163,9 @@ importers:
|
|||||||
'@tauri-apps/cli':
|
'@tauri-apps/cli':
|
||||||
specifier: ~2.11.1
|
specifier: ~2.11.1
|
||||||
version: 2.11.1
|
version: 2.11.1
|
||||||
|
'@types/canvas-confetti':
|
||||||
|
specifier: ^1.9.0
|
||||||
|
version: 1.9.0
|
||||||
'@types/color':
|
'@types/color':
|
||||||
specifier: ^4.2.1
|
specifier: ^4.2.1
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
@@ -1673,6 +1688,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-portal@1.1.10':
|
||||||
|
resolution: {integrity: sha512-4kY9IVa6+9nJPsYmngK5Uk2kUmZnv7ChhHAFeQ5oaj8jrR1bIi3xww8nH71pz1/Ve4d/cXO3YxT8eikt1B0a8w==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-portal@1.1.9':
|
'@radix-ui/react-portal@1.1.9':
|
||||||
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
|
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2483,6 +2511,9 @@ packages:
|
|||||||
'@types/body-parser@1.19.6':
|
'@types/body-parser@1.19.6':
|
||||||
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
|
||||||
|
|
||||||
|
'@types/canvas-confetti@1.9.0':
|
||||||
|
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
|
||||||
|
|
||||||
'@types/color-convert@2.0.4':
|
'@types/color-convert@2.0.4':
|
||||||
resolution: {integrity: sha512-Ub1MmDdyZ7mX//g25uBAoH/mWGd9swVbt8BseymnaE18SU4po/PjmCrHxqIIRjBo3hV/vh1KGr0eMxUhp+t+dQ==}
|
resolution: {integrity: sha512-Ub1MmDdyZ7mX//g25uBAoH/mWGd9swVbt8BseymnaE18SU4po/PjmCrHxqIIRjBo3hV/vh1KGr0eMxUhp+t+dQ==}
|
||||||
|
|
||||||
@@ -3012,6 +3043,9 @@ packages:
|
|||||||
caniuse-lite@1.0.30001792:
|
caniuse-lite@1.0.30001792:
|
||||||
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
|
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
|
||||||
|
|
||||||
|
canvas-confetti@1.9.4:
|
||||||
|
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -4285,6 +4319,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
onborda@1.2.5:
|
||||||
|
resolution: {integrity: sha512-S9EtQpKr8oYz7j0Bmr0w7BdG4Q4ud6QuNxBsSShzcf9khhuLEEjkbhYYMmdMlVa56QK/rXW/9pc8JJvBXUhOeA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@radix-ui/react-portal': '>=1.1.1'
|
||||||
|
framer-motion: '>=11'
|
||||||
|
next: '>=13'
|
||||||
|
react: '>=18'
|
||||||
|
react-dom: '>=18'
|
||||||
|
|
||||||
once@1.4.0:
|
once@1.4.0:
|
||||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||||
|
|
||||||
@@ -7002,6 +7045,16 @@ snapshots:
|
|||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||||
|
|
||||||
|
'@radix-ui/react-portal@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6)
|
||||||
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.14
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||||
|
|
||||||
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
@@ -7822,6 +7875,8 @@ snapshots:
|
|||||||
'@types/connect': 3.4.38
|
'@types/connect': 3.4.38
|
||||||
'@types/node': 25.7.0
|
'@types/node': 25.7.0
|
||||||
|
|
||||||
|
'@types/canvas-confetti@1.9.0': {}
|
||||||
|
|
||||||
'@types/color-convert@2.0.4':
|
'@types/color-convert@2.0.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/color-name': 1.1.5
|
'@types/color-name': 1.1.5
|
||||||
@@ -8372,6 +8427,8 @@ snapshots:
|
|||||||
|
|
||||||
caniuse-lite@1.0.30001792: {}
|
caniuse-lite@1.0.30001792: {}
|
||||||
|
|
||||||
|
canvas-confetti@1.9.4: {}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
@@ -9726,6 +9783,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ee-first: 1.1.1
|
ee-first: 1.1.1
|
||||||
|
|
||||||
|
onborda@1.2.5(@radix-ui/react-portal@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(framer-motion@12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-portal': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
framer-motion: 12.38.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
next: 16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
|
||||||
once@1.4.0:
|
once@1.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
wrappy: 1.0.2
|
wrappy: 1.0.2
|
||||||
|
|||||||
Generated
+101
-155
@@ -31,9 +31,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aes"
|
name = "aes"
|
||||||
version = "0.9.0"
|
version = "0.9.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8"
|
checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cipher 0.5.2",
|
"cipher 0.5.2",
|
||||||
"cpubits",
|
"cpubits",
|
||||||
@@ -169,7 +169,7 @@ version = "1.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -180,7 +180,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -745,9 +745,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "8.0.2"
|
version = "8.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
|
checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
"alloc-stdlib",
|
"alloc-stdlib",
|
||||||
@@ -756,9 +756,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli-decompressor"
|
name = "brotli-decompressor"
|
||||||
version = "5.0.0"
|
version = "5.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
|
checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
"alloc-stdlib",
|
"alloc-stdlib",
|
||||||
@@ -971,9 +971,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.62"
|
version = "1.2.63"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
"jobserver",
|
||||||
@@ -1068,9 +1068,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.45"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -1709,7 +1709,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users",
|
"redox_users",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1726,9 +1726,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -1784,9 +1784,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "donutbrowser"
|
name = "donutbrowser"
|
||||||
version = "0.24.3"
|
version = "0.26.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes 0.9.0",
|
"aes 0.9.1",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-socks5",
|
"async-socks5",
|
||||||
@@ -1827,7 +1827,7 @@ dependencies = [
|
|||||||
"quick-xml 0.40.1",
|
"quick-xml 0.40.1",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"regex-lite",
|
"regex-lite",
|
||||||
"reqwest 0.13.3",
|
"reqwest 0.13.4",
|
||||||
"resvg",
|
"resvg",
|
||||||
"ring",
|
"ring",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -1838,9 +1838,9 @@ dependencies = [
|
|||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"shadowsocks",
|
"shadowsocks",
|
||||||
"smoltcp",
|
"smoltcp",
|
||||||
|
"subtle",
|
||||||
"sys-locale",
|
"sys-locale",
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
"tao",
|
|
||||||
"tar",
|
"tar",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
@@ -1861,7 +1861,6 @@ dependencies = [
|
|||||||
"toml 1.1.2+spec-1.1.0",
|
"toml 1.1.2+spec-1.1.0",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tray-icon 0.24.0",
|
|
||||||
"url",
|
"url",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
@@ -2099,7 +2098,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2864,14 +2863,17 @@ name = "hashbrown"
|
|||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
dependencies = [
|
||||||
|
"foldhash 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashlink"
|
name = "hashlink"
|
||||||
version = "0.11.0"
|
version = "0.12.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230"
|
checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2938,9 +2940,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.0"
|
version = "1.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"itoa",
|
"itoa",
|
||||||
@@ -2998,9 +3000,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.9.0"
|
version = "1.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -3431,9 +3433,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jiff"
|
name = "jiff"
|
||||||
version = "0.2.24"
|
version = "0.2.28"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d"
|
checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jiff-static",
|
"jiff-static",
|
||||||
"log",
|
"log",
|
||||||
@@ -3444,9 +3446,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jiff-static"
|
name = "jiff-static"
|
||||||
version = "0.2.24"
|
version = "0.2.28"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7"
|
checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -3623,9 +3625,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libfuzzer-sys"
|
name = "libfuzzer-sys"
|
||||||
version = "0.4.12"
|
version = "0.4.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d"
|
checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arbitrary",
|
"arbitrary",
|
||||||
"cc",
|
"cc",
|
||||||
@@ -3649,43 +3651,24 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.16"
|
version = "0.1.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libsqlite3-sys"
|
name = "libsqlite3-sys"
|
||||||
version = "0.37.0"
|
version = "0.38.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
|
checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "libxdo"
|
|
||||||
version = "0.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db"
|
|
||||||
dependencies = [
|
|
||||||
"libxdo-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "libxdo-sys"
|
|
||||||
version = "0.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"x11",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -3709,9 +3692,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.29"
|
version = "0.4.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"value-bag",
|
"value-bag",
|
||||||
]
|
]
|
||||||
@@ -3820,9 +3803,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memmap2"
|
name = "memmap2"
|
||||||
@@ -3870,9 +3853,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.0"
|
version = "1.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
@@ -3923,7 +3906,6 @@ dependencies = [
|
|||||||
"dpi",
|
"dpi",
|
||||||
"gtk",
|
"gtk",
|
||||||
"keyboard-types",
|
"keyboard-types",
|
||||||
"libxdo",
|
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
@@ -3932,7 +3914,7 @@ dependencies = [
|
|||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4469,7 +4451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.45.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5345,9 +5327,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.3"
|
version = "0.13.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -5518,9 +5500,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rusqlite"
|
name = "rusqlite"
|
||||||
version = "0.39.0"
|
version = "0.40.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
|
checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"fallible-iterator",
|
"fallible-iterator",
|
||||||
@@ -5583,7 +5565,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5658,15 +5640,6 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "scc"
|
|
||||||
version = "2.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
|
|
||||||
dependencies = [
|
|
||||||
"sdd",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schannel"
|
name = "schannel"
|
||||||
version = "0.1.29"
|
version = "0.1.29"
|
||||||
@@ -5733,12 +5706,6 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sdd"
|
|
||||||
version = "3.0.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "seahash"
|
name = "seahash"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
@@ -5938,9 +5905,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.20.0"
|
version = "3.21.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
|
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bs58",
|
"bs58",
|
||||||
@@ -5958,9 +5925,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with_macros"
|
name = "serde_with_macros"
|
||||||
version = "3.20.0"
|
version = "3.21.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
|
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling",
|
"darling",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -5983,24 +5950,23 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serial_test"
|
name = "serial_test"
|
||||||
version = "3.4.0"
|
version = "3.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f"
|
checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-executor",
|
"futures-executor",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"scc",
|
|
||||||
"serial_test_derive",
|
"serial_test_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serial_test_derive"
|
name = "serial_test_derive"
|
||||||
version = "3.4.0"
|
version = "3.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9"
|
checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -6133,9 +6099,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "2.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sigchld"
|
name = "sigchld"
|
||||||
@@ -6247,12 +6213,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
version = "0.6.3"
|
version = "0.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6324,9 +6290,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlite-wasm-rs"
|
name = "sqlite-wasm-rs"
|
||||||
version = "0.5.4"
|
version = "0.5.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cdd578e94101503d97e2b286bbf8db2135035ca24b2ce4cbf3f9e2fb2bbf1eee"
|
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -6468,9 +6434,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sysinfo"
|
name = "sysinfo"
|
||||||
version = "0.39.2"
|
version = "0.39.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "14311e7e9a03114cd4b65eedd54e8fed2945e17f08586ae97ef53bc0669f9581"
|
checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -6606,6 +6572,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
|
"image",
|
||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -6619,7 +6586,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest 0.13.3",
|
"reqwest 0.13.4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -6632,7 +6599,7 @@ dependencies = [
|
|||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tray-icon 0.23.1",
|
"tray-icon",
|
||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
@@ -6973,7 +6940,7 @@ dependencies = [
|
|||||||
"serde_with",
|
"serde_with",
|
||||||
"swift-rs",
|
"swift-rs",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"toml 0.9.12+spec-1.1.0",
|
"toml 1.1.2+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
"urlpattern",
|
"urlpattern",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -7001,7 +6968,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7503,28 +7470,7 @@ dependencies = [
|
|||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tray-icon"
|
|
||||||
version = "0.24.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e47e6d063cfe4ad2e416fcbb310be3a37c5fd85c745b62cb562bfa4a003df674"
|
|
||||||
dependencies = [
|
|
||||||
"crossbeam-channel",
|
|
||||||
"dirs",
|
|
||||||
"libappindicator",
|
|
||||||
"muda",
|
|
||||||
"objc2",
|
|
||||||
"objc2-app-kit",
|
|
||||||
"objc2-core-foundation",
|
|
||||||
"objc2-core-graphics",
|
|
||||||
"objc2-foundation",
|
|
||||||
"once_cell",
|
|
||||||
"png 0.18.1",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"windows-sys 0.60.2",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7584,9 +7530,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.0"
|
version = "1.20.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uds_windows"
|
name = "uds_windows"
|
||||||
@@ -7596,7 +7542,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"memoffset",
|
"memoffset",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7684,9 +7630,9 @@ checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-segmentation"
|
name = "unicode-segmentation"
|
||||||
version = "1.13.2"
|
version = "1.13.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-vo"
|
name = "unicode-vo"
|
||||||
@@ -7844,9 +7790,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.1"
|
version = "1.23.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -8254,7 +8200,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8780,7 +8726,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9060,9 +9006,9 @@ checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.2"
|
version = "0.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
|
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
"yoke-derive",
|
"yoke-derive",
|
||||||
@@ -9083,9 +9029,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zbus"
|
name = "zbus"
|
||||||
version = "5.15.0"
|
version = "5.16.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1"
|
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-broadcast",
|
"async-broadcast",
|
||||||
"async-executor",
|
"async-executor",
|
||||||
@@ -9118,9 +9064,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zbus_macros"
|
name = "zbus_macros"
|
||||||
version = "5.15.0"
|
version = "5.16.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff"
|
checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro-crate 3.5.0",
|
"proc-macro-crate 3.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -9144,18 +9090,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.48"
|
version = "0.8.50"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.48"
|
version = "0.8.50"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -9351,9 +9297,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zvariant"
|
name = "zvariant"
|
||||||
version = "5.11.0"
|
version = "5.12.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee"
|
checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"endi",
|
"endi",
|
||||||
"enumflags2",
|
"enumflags2",
|
||||||
@@ -9365,9 +9311,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zvariant_derive"
|
name = "zvariant_derive"
|
||||||
version = "5.11.0"
|
version = "5.12.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda"
|
checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro-crate 3.5.0",
|
"proc-macro-crate 3.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -9378,9 +9324,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zvariant_utils"
|
name = "zvariant_utils"
|
||||||
version = "3.3.1"
|
version = "3.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691"
|
checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
+7
-12
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "donutbrowser"
|
name = "donutbrowser"
|
||||||
version = "0.24.3"
|
version = "0.26.0"
|
||||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||||
authors = ["zhom@github"]
|
authors = ["zhom@github"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -24,10 +24,6 @@ path = "src/main.rs"
|
|||||||
name = "donut-proxy"
|
name = "donut-proxy"
|
||||||
path = "src/bin/proxy_server.rs"
|
path = "src/bin/proxy_server.rs"
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "donut-daemon"
|
|
||||||
path = "src/bin/donut_daemon.rs"
|
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
resvg = "0.47"
|
resvg = "0.47"
|
||||||
@@ -35,7 +31,7 @@ resvg = "0.47"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
tauri = { version = "2", features = ["devtools", "test"] }
|
tauri = { version = "2", features = ["devtools", "test", "tray-icon", "image-png"] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-fs = "2"
|
tauri-plugin-fs = "2"
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
@@ -85,9 +81,10 @@ aes-gcm = "0.10"
|
|||||||
aes = "0.9"
|
aes = "0.9"
|
||||||
cbc = "0.2"
|
cbc = "0.2"
|
||||||
ring = "0.17"
|
ring = "0.17"
|
||||||
|
subtle = "2"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
shadowsocks = { version = "1.24", default-features = false, features = ["aead-cipher"] }
|
shadowsocks = { version = "1.24", default-features = false, features = ["aead-cipher"] }
|
||||||
hyper = { version = "1.8", features = ["full"] }
|
hyper = { version = "1.10", features = ["full"] }
|
||||||
hyper-util = { version = "0.1", features = ["full"] }
|
hyper-util = { version = "0.1", features = ["full"] }
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
@@ -98,7 +95,7 @@ playwright = { git = "https://github.com/zhom/playwright-rust", branch = "master
|
|||||||
|
|
||||||
# Wayfern CDP integration
|
# Wayfern CDP integration
|
||||||
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
|
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
|
||||||
rusqlite = { version = "0.39", features = ["bundled"] }
|
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
toml = "1.1"
|
toml = "1.1"
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
@@ -111,9 +108,7 @@ quick-xml = { version = "0.40", features = ["serialize"] }
|
|||||||
boringtun = "0.7"
|
boringtun = "0.7"
|
||||||
smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
|
smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
|
||||||
|
|
||||||
# Daemon dependencies (tray icon)
|
# Tray icon decoding (main-process system tray)
|
||||||
tray-icon = "0.24"
|
|
||||||
tao = "0.35"
|
|
||||||
image = "0.25"
|
image = "0.25"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
crossbeam-channel = "0.5"
|
crossbeam-channel = "0.5"
|
||||||
@@ -145,7 +140,7 @@ windows = { version = "0.62", features = [
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.24.0"
|
tempfile = "3.24.0"
|
||||||
wiremock = "0.6"
|
wiremock = "0.6"
|
||||||
hyper = { version = "1.8", features = ["full"] }
|
hyper = { version = "1.10", features = ["full"] }
|
||||||
hyper-util = { version = "0.1", features = ["full"] }
|
hyper-util = { version = "0.1", features = ["full"] }
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
|
|||||||
+5
-11
@@ -5,7 +5,7 @@ fn main() {
|
|||||||
// This allows running cargo test without building the frontend first
|
// This allows running cargo test without building the frontend first
|
||||||
ensure_dist_folder_exists();
|
ensure_dist_folder_exists();
|
||||||
|
|
||||||
// Generate tray icon PNGs from SVG (macOS template icon format)
|
// Generate tray icon PNG files from SVG (macOS template icon format)
|
||||||
generate_tray_icons();
|
generate_tray_icons();
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -93,19 +93,13 @@ fn external_binaries_exist() -> bool {
|
|||||||
let binaries_dir = PathBuf::from(&manifest_dir).join("binaries");
|
let binaries_dir = PathBuf::from(&manifest_dir).join("binaries");
|
||||||
|
|
||||||
// Check for all required external binaries (must match tauri.conf.json externalBin)
|
// Check for all required external binaries (must match tauri.conf.json externalBin)
|
||||||
let (donut_proxy_name, donut_daemon_name) = if target.contains("windows") {
|
let donut_proxy_name = if target.contains("windows") {
|
||||||
(
|
format!("donut-proxy-{}.exe", target)
|
||||||
format!("donut-proxy-{}.exe", target),
|
|
||||||
format!("donut-daemon-{}.exe", target),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
(
|
format!("donut-proxy-{}", target)
|
||||||
format!("donut-proxy-{}", target),
|
|
||||||
format!("donut-daemon-{}", target),
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
binaries_dir.join(&donut_proxy_name).exists() && binaries_dir.join(&donut_daemon_name).exists()
|
binaries_dir.join(&donut_proxy_name).exists()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_dist_folder_exists() {
|
fn ensure_dist_folder_exists() {
|
||||||
|
|||||||
@@ -21,6 +21,17 @@
|
|||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
|
{
|
||||||
|
"identifier": "opener:allow-open-url",
|
||||||
|
"allow": [
|
||||||
|
{
|
||||||
|
"url": "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"fs:default",
|
"fs:default",
|
||||||
"shell:allow-execute",
|
"shell:allow-execute",
|
||||||
"shell:allow-kill",
|
"shell:allow-kill",
|
||||||
|
|||||||
@@ -77,4 +77,3 @@ function copyBinary(baseName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
copyBinary("donut-proxy");
|
copyBinary("donut-proxy");
|
||||||
copyBinary("donut-daemon");
|
|
||||||
|
|||||||
@@ -102,6 +102,3 @@ copy_binary() {
|
|||||||
# Copy donut-proxy binary
|
# Copy donut-proxy binary
|
||||||
copy_binary "donut-proxy"
|
copy_binary "donut-proxy"
|
||||||
|
|
||||||
# Copy donut-daemon binary
|
|
||||||
copy_binary "donut-daemon"
|
|
||||||
|
|
||||||
|
|||||||
+182
-30
@@ -1,6 +1,5 @@
|
|||||||
use crate::browser::ProxySettings;
|
use crate::browser::ProxySettings;
|
||||||
use crate::camoufox_manager::CamoufoxConfig;
|
use crate::camoufox_manager::CamoufoxConfig;
|
||||||
use crate::daemon_ws::{ws_handler, WsState};
|
|
||||||
use crate::events;
|
use crate::events;
|
||||||
use crate::group_manager::GROUP_MANAGER;
|
use crate::group_manager::GROUP_MANAGER;
|
||||||
use crate::profile::manager::ProfileManager;
|
use crate::profile::manager::ProfileManager;
|
||||||
@@ -59,13 +58,25 @@ pub struct ApiProfileResponse {
|
|||||||
pub struct CreateProfileRequest {
|
pub struct CreateProfileRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub browser: String,
|
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 proxy_id: Option<String>,
|
||||||
pub vpn_id: Option<String>,
|
pub vpn_id: Option<String>,
|
||||||
pub launch_hook: Option<String>,
|
pub launch_hook: Option<String>,
|
||||||
pub release_type: 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)]
|
#[schema(value_type = Object)]
|
||||||
pub camoufox_config: Option<serde_json::Value>,
|
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)]
|
#[schema(value_type = Object)]
|
||||||
pub wayfern_config: Option<serde_json::Value>,
|
pub wayfern_config: Option<serde_json::Value>,
|
||||||
pub group_id: Option<String>,
|
pub group_id: Option<String>,
|
||||||
@@ -75,7 +86,9 @@ pub struct CreateProfileRequest {
|
|||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct UpdateProfileRequest {
|
pub struct UpdateProfileRequest {
|
||||||
pub name: Option<String>,
|
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 version: Option<String>,
|
||||||
pub proxy_id: Option<String>,
|
pub proxy_id: Option<String>,
|
||||||
pub vpn_id: Option<String>,
|
pub vpn_id: Option<String>,
|
||||||
@@ -406,22 +419,18 @@ impl ApiServer {
|
|||||||
let api = ApiDoc::openapi();
|
let api = ApiDoc::openapi();
|
||||||
|
|
||||||
let v1_routes = v1_routes
|
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(
|
.layer(middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
auth_middleware,
|
auth_middleware,
|
||||||
))
|
))
|
||||||
.layer(middleware::from_fn(terms_check_middleware));
|
.layer(middleware::from_fn(terms_check_middleware));
|
||||||
|
|
||||||
// Create WebSocket route with its own state (no auth required for daemon IPC)
|
|
||||||
let ws_state = WsState::new();
|
|
||||||
let ws_routes = Router::new()
|
|
||||||
.route("/events", get(ws_handler))
|
|
||||||
.with_state(ws_state);
|
|
||||||
|
|
||||||
let api_for_v1 = api.clone();
|
let api_for_v1 = api.clone();
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.merge(v1_routes)
|
.merge(v1_routes)
|
||||||
.nest("/ws", ws_routes)
|
|
||||||
.route("/openapi.json", get(move || async move { Json(api) }))
|
.route("/openapi.json", get(move || async move { Json(api) }))
|
||||||
.route(
|
.route(
|
||||||
"/v1/openapi.json",
|
"/v1/openapi.json",
|
||||||
@@ -516,8 +525,14 @@ async fn auth_middleware(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Compare tokens
|
// Constant-time comparison so the auth check doesn't leak the shared-prefix
|
||||||
if token != stored_token {
|
// 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");
|
log::warn!("[api] Rejected {path}: token mismatch");
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
@@ -558,6 +573,20 @@ async fn request_logging_middleware(request: axum::extract::Request, next: Next)
|
|||||||
response
|
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
|
// Global API server instance
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
|
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
|
||||||
@@ -594,6 +623,14 @@ pub async fn get_api_server_status() -> Result<Option<u16>, String> {
|
|||||||
Ok(server_guard.get_port())
|
Ok(server_guard.get_port())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
// API Handlers - Profiles
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
@@ -624,10 +661,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
|
|||||||
process_id: profile.process_id,
|
process_id: profile.process_id,
|
||||||
last_launch: profile.last_launch,
|
last_launch: profile.last_launch,
|
||||||
release_type: profile.release_type.clone(),
|
release_type: profile.release_type.clone(),
|
||||||
camoufox_config: profile
|
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref()),
|
||||||
.camoufox_config
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|c| serde_json::to_value(c).ok()),
|
|
||||||
group_id: profile.group_id.clone(),
|
group_id: profile.group_id.clone(),
|
||||||
tags: profile.tags.clone(),
|
tags: profile.tags.clone(),
|
||||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||||
@@ -681,10 +715,7 @@ async fn get_profile(
|
|||||||
process_id: profile.process_id,
|
process_id: profile.process_id,
|
||||||
last_launch: profile.last_launch,
|
last_launch: profile.last_launch,
|
||||||
release_type: profile.release_type.clone(),
|
release_type: profile.release_type.clone(),
|
||||||
camoufox_config: profile
|
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref()),
|
||||||
.camoufox_config
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|c| serde_json::to_value(c).ok()),
|
|
||||||
group_id: profile.group_id.clone(),
|
group_id: profile.group_id.clone(),
|
||||||
tags: profile.tags.clone(),
|
tags: profile.tags.clone(),
|
||||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||||
@@ -700,14 +731,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(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/v1/profiles",
|
path = "/v1/profiles",
|
||||||
request_body = CreateProfileRequest,
|
request_body = CreateProfileRequest,
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Profile created successfully", body = ApiProfileResponse),
|
(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 = 401, description = "Unauthorized"),
|
||||||
|
(status = 402, description = "Selected proxy requires payment"),
|
||||||
(status = 500, description = "Internal server error")
|
(status = 500, description = "Internal server error")
|
||||||
),
|
),
|
||||||
security(
|
security(
|
||||||
@@ -721,6 +762,34 @@ async fn create_profile(
|
|||||||
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
||||||
let profile_manager = ProfileManager::instance();
|
let profile_manager = ProfileManager::instance();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Parse camoufox config if provided
|
// Parse camoufox config if provided
|
||||||
let camoufox_config = if let Some(config) = &request.camoufox_config {
|
let camoufox_config = if let Some(config) = &request.camoufox_config {
|
||||||
serde_json::from_value(config.clone()).ok()
|
serde_json::from_value(config.clone()).ok()
|
||||||
@@ -735,13 +804,25 @@ async fn create_profile(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Reject a dead/unreachable proxy or VPN before creating the profile. A 402
|
||||||
|
// (expired proxy subscription) maps to 402; anything else is a 400.
|
||||||
|
if let Err(err) =
|
||||||
|
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
|
||||||
|
} else {
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Create profile using the async create_profile_with_group method
|
// Create profile using the async create_profile_with_group method
|
||||||
match profile_manager
|
match profile_manager
|
||||||
.create_profile_with_group(
|
.create_profile_with_group(
|
||||||
&state.app_handle,
|
&state.app_handle,
|
||||||
&request.name,
|
&request.name,
|
||||||
&request.browser,
|
&request.browser,
|
||||||
&request.version,
|
&version,
|
||||||
request.release_type.as_deref().unwrap_or("stable"),
|
request.release_type.as_deref().unwrap_or("stable"),
|
||||||
request.proxy_id.clone(),
|
request.proxy_id.clone(),
|
||||||
request.vpn_id.clone(),
|
request.vpn_id.clone(),
|
||||||
@@ -784,10 +865,7 @@ async fn create_profile(
|
|||||||
process_id: profile.process_id,
|
process_id: profile.process_id,
|
||||||
last_launch: profile.last_launch,
|
last_launch: profile.last_launch,
|
||||||
release_type: profile.release_type,
|
release_type: profile.release_type,
|
||||||
camoufox_config: profile
|
camoufox_config: config_to_api_value(profile.camoufox_config.as_ref()),
|
||||||
.camoufox_config
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|c| serde_json::to_value(c).ok()),
|
|
||||||
group_id: profile.group_id,
|
group_id: profile.group_id,
|
||||||
tags: profile.tags,
|
tags: profile.tags,
|
||||||
is_running: false,
|
is_running: false,
|
||||||
@@ -892,6 +970,14 @@ async fn update_profile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(camoufox_config) = request.camoufox_config {
|
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);
|
let config: Result<CamoufoxConfig, _> = serde_json::from_value(camoufox_config);
|
||||||
match config {
|
match config {
|
||||||
Ok(config) => {
|
Ok(config) => {
|
||||||
@@ -1710,7 +1796,7 @@ async fn run_profile(
|
|||||||
Json(request): Json<RunProfileRequest>,
|
Json(request): Json<RunProfileRequest>,
|
||||||
) -> Result<Json<RunProfileResponse>, StatusCode> {
|
) -> Result<Json<RunProfileResponse>, StatusCode> {
|
||||||
if !crate::cloud_auth::CLOUD_AUTH
|
if !crate::cloud_auth::CLOUD_AUTH
|
||||||
.has_active_paid_subscription()
|
.can_use_browser_automation()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||||
@@ -1750,13 +1836,15 @@ async fn run_profile(
|
|||||||
port
|
port
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use the same launch method as the main app, but with remote debugging enabled
|
// Use the same launch path as the main app, but force a fresh instance with
|
||||||
match crate::browser_runner::launch_browser_profile_with_debugging(
|
// remote debugging enabled so the returned port is the one the browser binds.
|
||||||
|
match crate::browser_runner::launch_browser_profile_impl(
|
||||||
state.app_handle.clone(),
|
state.app_handle.clone(),
|
||||||
profile.clone(),
|
profile.clone(),
|
||||||
url,
|
url,
|
||||||
Some(remote_debugging_port),
|
Some(remote_debugging_port),
|
||||||
headless,
|
headless,
|
||||||
|
true,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -1794,7 +1882,7 @@ async fn open_url_in_profile(
|
|||||||
Json(request): Json<OpenUrlRequest>,
|
Json(request): Json<OpenUrlRequest>,
|
||||||
) -> Result<StatusCode, StatusCode> {
|
) -> Result<StatusCode, StatusCode> {
|
||||||
if !crate::cloud_auth::CLOUD_AUTH
|
if !crate::cloud_auth::CLOUD_AUTH
|
||||||
.has_active_paid_subscription()
|
.can_use_browser_automation()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||||
@@ -1820,6 +1908,7 @@ async fn open_url_in_profile(
|
|||||||
responses(
|
responses(
|
||||||
(status = 204, description = "Browser process killed successfully"),
|
(status = 204, description = "Browser process killed successfully"),
|
||||||
(status = 401, description = "Unauthorized"),
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 402, description = "Active paid plan required"),
|
||||||
(status = 404, description = "Profile not found"),
|
(status = 404, description = "Profile not found"),
|
||||||
(status = 500, description = "Internal server error")
|
(status = 500, description = "Internal server error")
|
||||||
),
|
),
|
||||||
@@ -1832,6 +1921,15 @@ async fn kill_profile(
|
|||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
State(state): State<ApiServerState>,
|
State(state): State<ApiServerState>,
|
||||||
) -> Result<StatusCode, StatusCode> {
|
) -> 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 profile_manager = ProfileManager::instance();
|
||||||
let profiles = profile_manager
|
let profiles = profile_manager
|
||||||
.list_profiles()
|
.list_profiles()
|
||||||
@@ -2067,3 +2165,57 @@ async fn refresh_wayfern_token(
|
|||||||
let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
||||||
Ok(Json(WayfernTokenResponse { token }))
|
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(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,23 @@ pub fn is_portable() -> bool {
|
|||||||
portable_dir().is_some()
|
portable_dir().is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Optional single-root override for all on-disk state. Set
|
||||||
|
/// `DONUTBROWSER_DATA_ROOT=/path` (e.g. a tmpfs mount) to relocate
|
||||||
|
/// data/cache/logs under `<root>/{data,cache,logs}` without touching the real
|
||||||
|
/// dev/prod directories. The more specific `DONUTBROWSER_DATA_DIR` /
|
||||||
|
/// `DONUTBROWSER_CACHE_DIR` overrides still take precedence over this.
|
||||||
|
fn data_root() -> Option<PathBuf> {
|
||||||
|
std::env::var_os("DONUTBROWSER_DATA_ROOT")
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log directory when `DONUTBROWSER_DATA_ROOT` is set (`<root>/logs`); `None`
|
||||||
|
/// otherwise, in which case the platform default app log dir is used.
|
||||||
|
pub fn log_dir_override() -> Option<PathBuf> {
|
||||||
|
data_root().map(|root| root.join("logs"))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn app_name() -> &'static str {
|
pub fn app_name() -> &'static str {
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
"DonutBrowserDev"
|
"DonutBrowserDev"
|
||||||
@@ -46,6 +63,10 @@ pub fn data_dir() -> PathBuf {
|
|||||||
return PathBuf::from(dir);
|
return PathBuf::from(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(root) = data_root() {
|
||||||
|
return root.join("data");
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(dir) = portable_dir() {
|
if let Some(dir) = portable_dir() {
|
||||||
return dir.join("data");
|
return dir.join("data");
|
||||||
}
|
}
|
||||||
@@ -65,6 +86,10 @@ pub fn cache_dir() -> PathBuf {
|
|||||||
return PathBuf::from(dir);
|
return PathBuf::from(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(root) = data_root() {
|
||||||
|
return root.join("cache");
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(dir) = portable_dir() {
|
if let Some(dir) = portable_dir() {
|
||||||
return dir.join("cache");
|
return dir.join("cache");
|
||||||
}
|
}
|
||||||
@@ -112,6 +137,9 @@ pub fn dns_blocklist_dir() -> PathBuf {
|
|||||||
/// `LogDir` target used in the plugin builder so the path matches what's
|
/// `LogDir` target used in the plugin builder so the path matches what's
|
||||||
/// actually on disk for this OS.
|
/// actually on disk for this OS.
|
||||||
pub fn log_dir<R: tauri::Runtime>(handle: &tauri::AppHandle<R>) -> PathBuf {
|
pub fn log_dir<R: tauri::Runtime>(handle: &tauri::AppHandle<R>) -> PathBuf {
|
||||||
|
if let Some(dir) = log_dir_override() {
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
handle
|
handle
|
||||||
.path()
|
.path()
|
||||||
|
|||||||
@@ -703,6 +703,7 @@ mod tests {
|
|||||||
dns_blocklist: None,
|
dns_blocklist: None,
|
||||||
password_protected: false,
|
password_protected: false,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
|
updated_at: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,498 +0,0 @@
|
|||||||
// Donut Browser Daemon - Background process for tray icon and services
|
|
||||||
// This runs independently of the main Tauri GUI
|
|
||||||
|
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
||||||
|
|
||||||
use std::env;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::process;
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::mpsc;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tao::event::{Event, StartCause};
|
|
||||||
use tao::event_loop::{ControlFlow, EventLoopBuilder};
|
|
||||||
use tokio::runtime::Runtime;
|
|
||||||
use tray_icon::menu::MenuEvent;
|
|
||||||
use tray_icon::TrayIcon;
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
use tray_icon::{MouseButton, TrayIconEvent};
|
|
||||||
|
|
||||||
use donutbrowser_lib::daemon::{autostart, services, tray};
|
|
||||||
|
|
||||||
static SHOULD_QUIT: AtomicBool = AtomicBool::new(false);
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn win_process_exists(pid: u32) -> bool {
|
|
||||||
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
|
|
||||||
|
|
||||||
extern "system" {
|
|
||||||
fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut ();
|
|
||||||
fn CloseHandle(hObject: *mut ()) -> i32;
|
|
||||||
}
|
|
||||||
|
|
||||||
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
|
||||||
if handle.is_null() {
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
unsafe { CloseHandle(handle) };
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ServiceStatus {
|
|
||||||
Ready {
|
|
||||||
api_port: Option<u16>,
|
|
||||||
mcp_running: bool,
|
|
||||||
},
|
|
||||||
Failed(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
struct DaemonState {
|
|
||||||
daemon_pid: Option<u32>,
|
|
||||||
api_port: Option<u16>,
|
|
||||||
mcp_running: bool,
|
|
||||||
version: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_state_path() -> PathBuf {
|
|
||||||
autostart::get_data_dir()
|
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
|
||||||
.join("daemon-state.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_data_dir() -> std::io::Result<()> {
|
|
||||||
if let Some(data_dir) = autostart::get_data_dir() {
|
|
||||||
fs::create_dir_all(&data_dir)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_state() -> DaemonState {
|
|
||||||
let path = get_state_path();
|
|
||||||
if path.exists() {
|
|
||||||
if let Ok(content) = fs::read_to_string(&path) {
|
|
||||||
if let Ok(state) = serde_json::from_str(&content) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DaemonState::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_state(state: &DaemonState) -> std::io::Result<()> {
|
|
||||||
let path = get_state_path();
|
|
||||||
let content = serde_json::to_string_pretty(state)?;
|
|
||||||
fs::write(path, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_high_priority() {
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
// Set high priority so the daemon is killed last under resource pressure
|
|
||||||
// Negative nice value = higher priority. Try -10, fall back to -5 if it fails.
|
|
||||||
unsafe {
|
|
||||||
if libc::setpriority(libc::PRIO_PROCESS, 0, -10) != 0 {
|
|
||||||
let _ = libc::setpriority(libc::PRIO_PROCESS, 0, -5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
use windows::Win32::Foundation::CloseHandle;
|
|
||||||
use windows::Win32::System::Threading::{
|
|
||||||
GetCurrentProcess, SetPriorityClass, ABOVE_NORMAL_PRIORITY_CLASS,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set high priority so the daemon is killed last under resource pressure
|
|
||||||
unsafe {
|
|
||||||
let handle = GetCurrentProcess();
|
|
||||||
let _ = SetPriorityClass(handle, ABOVE_NORMAL_PRIORITY_CLASS);
|
|
||||||
// GetCurrentProcess returns a pseudo-handle that doesn't need to be closed,
|
|
||||||
// but we do it anyway for consistency
|
|
||||||
let _ = CloseHandle(handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_daemon() {
|
|
||||||
// Set high priority so the daemon is less likely to be killed under resource pressure
|
|
||||||
set_high_priority();
|
|
||||||
|
|
||||||
// Initialize logging to file for debugging (since stdout/stderr may be redirected)
|
|
||||||
let log_path = autostart::get_data_dir()
|
|
||||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
|
||||||
.join("daemon.log");
|
|
||||||
|
|
||||||
let log_file = std::fs::OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.append(true)
|
|
||||||
.open(&log_path);
|
|
||||||
|
|
||||||
env_logger::Builder::from_default_env()
|
|
||||||
.filter_level(log::LevelFilter::Info)
|
|
||||||
.format_timestamp_millis()
|
|
||||||
.target(if let Ok(file) = log_file {
|
|
||||||
env_logger::Target::Pipe(Box::new(file))
|
|
||||||
} else {
|
|
||||||
env_logger::Target::Stderr
|
|
||||||
})
|
|
||||||
.init();
|
|
||||||
|
|
||||||
if let Err(e) = ensure_data_dir() {
|
|
||||||
eprintln!("Failed to create data directory: {}", e);
|
|
||||||
process::exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("[daemon] Starting with PID {}", process::id());
|
|
||||||
|
|
||||||
// Create tokio runtime for async operations
|
|
||||||
let rt = Runtime::new().expect("Failed to create tokio runtime");
|
|
||||||
|
|
||||||
// Create channel for service status updates
|
|
||||||
let (tx, rx) = mpsc::channel::<ServiceStatus>();
|
|
||||||
|
|
||||||
// Spawn services in a background thread so we don't block the event loop
|
|
||||||
let rt_handle = rt.handle().clone();
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let result = rt_handle.block_on(async { services::DaemonServices::start().await });
|
|
||||||
let status = match result {
|
|
||||||
Ok(s) => ServiceStatus::Ready {
|
|
||||||
api_port: s.api_port,
|
|
||||||
mcp_running: s.mcp_running,
|
|
||||||
},
|
|
||||||
Err(e) => ServiceStatus::Failed(e),
|
|
||||||
};
|
|
||||||
let _ = tx.send(status);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Write initial state (services still starting)
|
|
||||||
let state = DaemonState {
|
|
||||||
daemon_pid: Some(process::id()),
|
|
||||||
api_port: None,
|
|
||||||
mcp_running: false,
|
|
||||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
|
||||||
};
|
|
||||||
if let Err(e) = write_state(&state) {
|
|
||||||
log::error!("Failed to write state: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare tray menu and icon (but don't create the tray icon yet)
|
|
||||||
let tray_menu = tray::TrayMenu::new();
|
|
||||||
|
|
||||||
let icon = tray::load_icon();
|
|
||||||
let menu_channel = MenuEvent::receiver();
|
|
||||||
|
|
||||||
// Create the event loop IMMEDIATELY (critical for macOS tray icon)
|
|
||||||
let event_loop = EventLoopBuilder::new().build();
|
|
||||||
|
|
||||||
// Store tray icon in Option - created after event loop starts
|
|
||||||
let mut tray_icon: Option<TrayIcon> = None;
|
|
||||||
|
|
||||||
// Install signal handlers so SIGTERM/SIGINT trigger graceful shutdown
|
|
||||||
#[cfg(unix)]
|
|
||||||
unsafe {
|
|
||||||
extern "C" fn signal_handler(_sig: libc::c_int) {
|
|
||||||
SHOULD_QUIT.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
libc::signal(
|
|
||||||
libc::SIGTERM,
|
|
||||||
signal_handler as *const () as libc::sighandler_t,
|
|
||||||
);
|
|
||||||
libc::signal(
|
|
||||||
libc::SIGINT,
|
|
||||||
signal_handler as *const () as libc::sighandler_t,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
extern "system" {
|
|
||||||
fn SetConsoleCtrlHandler(
|
|
||||||
handler: Option<unsafe extern "system" fn(u32) -> i32>,
|
|
||||||
add: i32,
|
|
||||||
) -> i32;
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe extern "system" fn ctrl_handler(_ctrl_type: u32) -> i32 {
|
|
||||||
SHOULD_QUIT.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
1 // TRUE
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe {
|
|
||||||
SetConsoleCtrlHandler(Some(ctrl_handler), 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the event loop
|
|
||||||
event_loop.run(move |event, _, control_flow| {
|
|
||||||
// Use WaitUntil to check for menu events periodically while staying low on CPU
|
|
||||||
*control_flow = ControlFlow::WaitUntil(Instant::now() + Duration::from_millis(100));
|
|
||||||
|
|
||||||
match event {
|
|
||||||
Event::NewEvents(StartCause::Init) => {
|
|
||||||
// Hide from dock on macOS (must be done after event loop starts)
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
use objc2::MainThreadMarker;
|
|
||||||
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
|
|
||||||
|
|
||||||
if let Some(mtm) = MainThreadMarker::new() {
|
|
||||||
let app = NSApplication::sharedApplication(mtm);
|
|
||||||
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create tray icon after event loop has started (required for macOS)
|
|
||||||
tray_icon = Some(tray::create_tray_icon(icon.clone(), &tray_menu.menu));
|
|
||||||
log::info!("[daemon] Tray icon created");
|
|
||||||
}
|
|
||||||
Event::MainEventsCleared => {
|
|
||||||
// Check for service status updates from background thread
|
|
||||||
if let Ok(status) = rx.try_recv() {
|
|
||||||
match status {
|
|
||||||
ServiceStatus::Ready {
|
|
||||||
api_port,
|
|
||||||
mcp_running,
|
|
||||||
} => {
|
|
||||||
log::info!("[daemon] Services started successfully");
|
|
||||||
|
|
||||||
// Update state file
|
|
||||||
let mut state = read_state();
|
|
||||||
state.api_port = api_port;
|
|
||||||
state.mcp_running = mcp_running;
|
|
||||||
if let Err(e) = write_state(&state) {
|
|
||||||
log::error!("Failed to write state: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ServiceStatus::Failed(e) => {
|
|
||||||
log::error!("Failed to start services: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process menu events
|
|
||||||
while let Ok(event) = menu_channel.try_recv() {
|
|
||||||
if event.id == tray_menu.quit_item.id() {
|
|
||||||
log::info!("[daemon] Quit requested");
|
|
||||||
SHOULD_QUIT.store(true, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle tray icon click (left-click opens the app)
|
|
||||||
// On macOS, left-click already shows the menu, so don't also launch the GUI.
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
while let Ok(event) = TrayIconEvent::receiver().try_recv() {
|
|
||||||
if let TrayIconEvent::Click {
|
|
||||||
button: MouseButton::Left,
|
|
||||||
..
|
|
||||||
} = event
|
|
||||||
{
|
|
||||||
tray::open_gui();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use swap to only run cleanup once
|
|
||||||
if SHOULD_QUIT.swap(false, Ordering::SeqCst) {
|
|
||||||
// Remove tray icon from status bar immediately so the UI feels responsive
|
|
||||||
tray_icon = None;
|
|
||||||
|
|
||||||
tray::quit_gui();
|
|
||||||
|
|
||||||
let mut state = read_state();
|
|
||||||
state.daemon_pid = None;
|
|
||||||
let _ = write_state(&state);
|
|
||||||
log::info!("[daemon] Exiting");
|
|
||||||
|
|
||||||
// Use process::exit for immediate termination instead of ControlFlow::Exit.
|
|
||||||
// ControlFlow::Exit can delay because tao's macOS event loop defers exit,
|
|
||||||
// and dropping the tokio runtime blocks until all spawned tasks finish.
|
|
||||||
process::exit(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Event::Reopen { .. } => {
|
|
||||||
tray::open_gui();
|
|
||||||
|
|
||||||
// Re-hide daemon from Dock. macOS activates the daemon (making it
|
|
||||||
// visible) when the user clicks the Dock icon, overriding the
|
|
||||||
// Accessory policy set at init.
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
use objc2::MainThreadMarker;
|
|
||||||
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
|
|
||||||
|
|
||||||
if let Some(mtm) = MainThreadMarker::new() {
|
|
||||||
let app = NSApplication::sharedApplication(mtm);
|
|
||||||
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep tray_icon alive
|
|
||||||
let _ = &tray_icon;
|
|
||||||
|
|
||||||
// Keep runtime alive
|
|
||||||
let _ = &rt;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stop_daemon() {
|
|
||||||
let state = read_state();
|
|
||||||
|
|
||||||
if let Some(pid) = state.daemon_pid {
|
|
||||||
// On Windows, taskkill /F kills instantly with no handler, so kill GUI first
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
use std::process::Command;
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
||||||
|
|
||||||
let state_path = get_state_path();
|
|
||||||
if let Ok(content) = fs::read_to_string(&state_path) {
|
|
||||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
|
|
||||||
if let Some(gui_pid) = val.get("gui_pid").and_then(|v| v.as_u64()) {
|
|
||||||
let _ = Command::new("taskkill")
|
|
||||||
.args(["/PID", &gui_pid.to_string(), "/F"])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.output();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = Command::new("taskkill")
|
|
||||||
.args(["/PID", &pid.to_string(), "/F"])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.output();
|
|
||||||
eprintln!("Sent stop signal to daemon (PID {})", pid);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
unsafe {
|
|
||||||
libc::kill(pid as i32, libc::SIGTERM);
|
|
||||||
}
|
|
||||||
eprintln!("Sent stop signal to daemon (PID {})", pid);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
eprintln!("Daemon is not running");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn show_status() {
|
|
||||||
let state = read_state();
|
|
||||||
|
|
||||||
if let Some(pid) = state.daemon_pid {
|
|
||||||
#[cfg(unix)]
|
|
||||||
let is_running = unsafe { libc::kill(pid as i32, 0) == 0 };
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
let is_running = win_process_exists(pid);
|
|
||||||
|
|
||||||
#[cfg(not(any(unix, windows)))]
|
|
||||||
let is_running = false;
|
|
||||||
|
|
||||||
if is_running {
|
|
||||||
eprintln!("Daemon is running (PID {})", pid);
|
|
||||||
if let Some(port) = state.api_port {
|
|
||||||
eprintln!(" API: Running on port {}", port);
|
|
||||||
} else {
|
|
||||||
eprintln!(" API: Stopped");
|
|
||||||
}
|
|
||||||
eprintln!(
|
|
||||||
" MCP: {}",
|
|
||||||
if state.mcp_running {
|
|
||||||
"Running"
|
|
||||||
} else {
|
|
||||||
"Stopped"
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
eprintln!("Daemon is not running (stale PID in state file)");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
eprintln!("Daemon is not running");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_usage() {
|
|
||||||
eprintln!("Donut Browser Daemon");
|
|
||||||
eprintln!();
|
|
||||||
eprintln!("Usage: donut-daemon <command>");
|
|
||||||
eprintln!();
|
|
||||||
eprintln!("Commands:");
|
|
||||||
eprintln!(" start Start the daemon (detaches from terminal)");
|
|
||||||
eprintln!(" stop Stop the running daemon");
|
|
||||||
eprintln!(" status Show daemon status");
|
|
||||||
eprintln!(" run Run in foreground (for debugging)");
|
|
||||||
eprintln!(" autostart Manage autostart settings");
|
|
||||||
eprintln!(" enable Enable autostart on login");
|
|
||||||
eprintln!(" disable Disable autostart on login");
|
|
||||||
eprintln!(" status Show autostart status");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let args: Vec<String> = env::args().collect();
|
|
||||||
|
|
||||||
if args.len() < 2 {
|
|
||||||
print_usage();
|
|
||||||
process::exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
match args[1].as_str() {
|
|
||||||
"start" => {
|
|
||||||
run_daemon();
|
|
||||||
}
|
|
||||||
"stop" => {
|
|
||||||
stop_daemon();
|
|
||||||
}
|
|
||||||
"status" => {
|
|
||||||
show_status();
|
|
||||||
}
|
|
||||||
"run" => {
|
|
||||||
run_daemon();
|
|
||||||
}
|
|
||||||
"autostart" => {
|
|
||||||
if args.len() < 3 {
|
|
||||||
eprintln!("Usage: donut-daemon autostart <enable|disable|status>");
|
|
||||||
process::exit(1);
|
|
||||||
}
|
|
||||||
match args[2].as_str() {
|
|
||||||
"enable" => {
|
|
||||||
if let Err(e) = autostart::enable_autostart() {
|
|
||||||
eprintln!("Failed to enable autostart: {}", e);
|
|
||||||
process::exit(1);
|
|
||||||
}
|
|
||||||
eprintln!("Autostart enabled");
|
|
||||||
}
|
|
||||||
"disable" => {
|
|
||||||
if let Err(e) = autostart::disable_autostart() {
|
|
||||||
eprintln!("Failed to disable autostart: {}", e);
|
|
||||||
process::exit(1);
|
|
||||||
}
|
|
||||||
eprintln!("Autostart disabled");
|
|
||||||
}
|
|
||||||
"status" => {
|
|
||||||
if autostart::is_autostart_enabled() {
|
|
||||||
eprintln!("Autostart is enabled");
|
|
||||||
} else {
|
|
||||||
eprintln!("Autostart is disabled");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
eprintln!("Unknown autostart command: {}", args[2]);
|
|
||||||
process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
print_usage();
|
|
||||||
process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1220,6 +1220,7 @@ mod tests {
|
|||||||
dns_blocklist: None,
|
dns_blocklist: None,
|
||||||
password_protected: false,
|
password_protected: false,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let path = profile.get_profile_data_path(&profiles_dir);
|
let path = profile.get_profile_data_path(&profiles_dir);
|
||||||
|
|||||||
+68
-276
@@ -7,78 +7,11 @@ use crate::platform_browser;
|
|||||||
use crate::profile::{BrowserProfile, ProfileManager};
|
use crate::profile::{BrowserProfile, ProfileManager};
|
||||||
use crate::proxy_manager::PROXY_MANAGER;
|
use crate::proxy_manager::PROXY_MANAGER;
|
||||||
use crate::wayfern_manager::{WayfernConfig, WayfernManager};
|
use crate::wayfern_manager::{WayfernConfig, WayfernManager};
|
||||||
use chrono::{Datelike, TimeZone, Utc};
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
use sysinfo::System;
|
use sysinfo::System;
|
||||||
|
|
||||||
/// Fixed UTC hour at which Wayfern fingerprints rotate. Picked to land in a
|
|
||||||
/// low-traffic window for the average user; everyone shares the same UTC
|
|
||||||
/// instant so the value here doesn't track any one user's local schedule.
|
|
||||||
const FINGERPRINT_ROLLOVER_HOUR_UTC: u32 = 4;
|
|
||||||
|
|
||||||
/// File name of the per-profile marker recording the last fingerprint
|
|
||||||
/// refresh time. Lives at `<profiles_dir>/<profile_id>/.last-fp-refresh`
|
|
||||||
/// and is excluded from cloud sync (see `sync::manifest`) so each device
|
|
||||||
/// runs its own refresh schedule.
|
|
||||||
const LAST_FP_REFRESH_FILE: &str = ".last-fp-refresh";
|
|
||||||
|
|
||||||
/// Most recent rollover instant on or before `now` — used as a staleness
|
|
||||||
/// threshold for Wayfern fingerprints. Anything generated before this
|
|
||||||
/// timestamp is considered stale and gets regenerated on next launch.
|
|
||||||
fn most_recent_rollover_epoch() -> u64 {
|
|
||||||
let now = Utc::now();
|
|
||||||
let today_threshold = Utc
|
|
||||||
.with_ymd_and_hms(
|
|
||||||
now.year(),
|
|
||||||
now.month(),
|
|
||||||
now.day(),
|
|
||||||
FINGERPRINT_ROLLOVER_HOUR_UTC,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
.single()
|
|
||||||
.unwrap_or(now);
|
|
||||||
let threshold = if now >= today_threshold {
|
|
||||||
today_threshold
|
|
||||||
} else {
|
|
||||||
today_threshold - chrono::Duration::days(1)
|
|
||||||
};
|
|
||||||
threshold.timestamp().max(0) as u64
|
|
||||||
}
|
|
||||||
|
|
||||||
fn last_fp_refresh_path(profile_id: &str, profiles_dir: &std::path::Path) -> PathBuf {
|
|
||||||
profiles_dir.join(profile_id).join(LAST_FP_REFRESH_FILE)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the epoch-seconds timestamp stored in the per-profile refresh marker.
|
|
||||||
/// Returns `None` if the file doesn't exist or its content can't be parsed —
|
|
||||||
/// both signal "needs a refresh" to the caller.
|
|
||||||
fn read_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path) -> Option<u64> {
|
|
||||||
let path = last_fp_refresh_path(profile_id, profiles_dir);
|
|
||||||
let content = std::fs::read_to_string(&path).ok()?;
|
|
||||||
content.trim().parse::<u64>().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record `ts` (epoch seconds) as the most recent fingerprint refresh for
|
|
||||||
/// this profile. Failure is logged but never propagated — a missing marker
|
|
||||||
/// only costs an extra regen on the next launch, never blocks one.
|
|
||||||
fn write_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path, ts: u64) {
|
|
||||||
let path = last_fp_refresh_path(profile_id, profiles_dir);
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
if !parent.exists() {
|
|
||||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
|
||||||
log::warn!("Failed to create profile dir for fingerprint refresh marker {profile_id}: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Err(e) = std::fs::write(&path, ts.to_string()) {
|
|
||||||
log::warn!("Failed to write fingerprint refresh marker for {profile_id}: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct BrowserRunner {
|
pub struct BrowserRunner {
|
||||||
pub profile_manager: &'static ProfileManager,
|
pub profile_manager: &'static ProfileManager,
|
||||||
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
|
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
|
||||||
@@ -448,6 +381,7 @@ impl BrowserRunner {
|
|||||||
camoufox_config,
|
camoufox_config,
|
||||||
url,
|
url,
|
||||||
override_profile_path,
|
override_profile_path,
|
||||||
|
remote_debugging_port,
|
||||||
headless,
|
headless,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -612,32 +546,12 @@ impl BrowserRunner {
|
|||||||
wayfern_config.proxy
|
wayfern_config.proxy
|
||||||
);
|
);
|
||||||
|
|
||||||
// Decide whether to (re)generate the Wayfern fingerprint for this
|
// Check if we need to generate a new fingerprint on every launch
|
||||||
// launch. Two triggers:
|
|
||||||
//
|
|
||||||
// 1. `randomize_fingerprint_on_launch = true` — explicit per-launch
|
|
||||||
// randomization the user opted into.
|
|
||||||
// 2. The fingerprint hasn't been refreshed since the most recent
|
|
||||||
// rollover instant. We check the per-profile marker file first
|
|
||||||
// (`.last-fp-refresh`); if it's absent we fall back to
|
|
||||||
// `profile.created_at` so brand-new profiles don't immediately
|
|
||||||
// regenerate the fingerprint they were just created with.
|
|
||||||
// Profiles with neither (truly legacy) are treated as ancient
|
|
||||||
// and refresh on next launch — once.
|
|
||||||
let mut updated_profile = profile.clone();
|
let mut updated_profile = profile.clone();
|
||||||
let stale_threshold = most_recent_rollover_epoch();
|
if wayfern_config.randomize_fingerprint_on_launch == Some(true) {
|
||||||
let profile_id_str = profile.id.to_string();
|
|
||||||
let profiles_dir_for_marker = self.profile_manager.get_profiles_dir();
|
|
||||||
let effective_last_refresh =
|
|
||||||
read_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker).or(profile.created_at);
|
|
||||||
let is_stale_profile = effective_last_refresh.is_none_or(|ts| ts < stale_threshold);
|
|
||||||
let randomize_every_launch = wayfern_config.randomize_fingerprint_on_launch == Some(true);
|
|
||||||
if randomize_every_launch || is_stale_profile {
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Generating Wayfern fingerprint for profile {} (per-launch={}, rollover={})",
|
"Generating random fingerprint for Wayfern profile: {}",
|
||||||
profile.name,
|
profile.name
|
||||||
randomize_every_launch,
|
|
||||||
is_stale_profile
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create a config copy without the existing fingerprint to force generation of a new one
|
// Create a config copy without the existing fingerprint to force generation of a new one
|
||||||
@@ -659,24 +573,12 @@ impl BrowserRunner {
|
|||||||
// Update the config with the new fingerprint for launching
|
// Update the config with the new fingerprint for launching
|
||||||
wayfern_config.fingerprint = Some(new_fingerprint.clone());
|
wayfern_config.fingerprint = Some(new_fingerprint.clone());
|
||||||
|
|
||||||
// Write the marker so the next launch within the same rollover
|
|
||||||
// window skips this branch. The marker is excluded from cloud
|
|
||||||
// sync (see `sync::manifest::DEFAULT_EXCLUDE_PATTERNS`), so each
|
|
||||||
// device's refresh schedule is independent.
|
|
||||||
let now_epoch = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs())
|
|
||||||
.unwrap_or(stale_threshold);
|
|
||||||
write_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker, now_epoch);
|
|
||||||
|
|
||||||
// Save the updated fingerprint to the profile so it persists.
|
// Save the updated fingerprint to the profile so it persists.
|
||||||
let mut updated_wayfern_config = updated_profile.wayfern_config.clone().unwrap_or_default();
|
let mut updated_wayfern_config = updated_profile.wayfern_config.clone().unwrap_or_default();
|
||||||
updated_wayfern_config.fingerprint = Some(new_fingerprint);
|
updated_wayfern_config.fingerprint = Some(new_fingerprint);
|
||||||
// Preserve the user's randomize-on-launch preference rather than
|
// Preserve the randomize flag so it persists across launches
|
||||||
// forcing it on. The rollover path must not silently flip this
|
updated_wayfern_config.randomize_fingerprint_on_launch = Some(true);
|
||||||
// flag for users who only opted into the scheduled refresh.
|
// Preserve the OS setting so it's used for future fingerprint generation
|
||||||
updated_wayfern_config.randomize_fingerprint_on_launch =
|
|
||||||
wayfern_config.randomize_fingerprint_on_launch;
|
|
||||||
if wayfern_config.os.is_some() {
|
if wayfern_config.os.is_some() {
|
||||||
updated_wayfern_config.os = wayfern_config.os.clone();
|
updated_wayfern_config.os = wayfern_config.os.clone();
|
||||||
}
|
}
|
||||||
@@ -754,6 +656,24 @@ impl BrowserRunner {
|
|||||||
let process_id = wayfern_result.processId.unwrap_or(0);
|
let process_id = wayfern_result.processId.unwrap_or(0);
|
||||||
log::info!("Wayfern launched successfully with PID: {process_id}");
|
log::info!("Wayfern launched successfully with PID: {process_id}");
|
||||||
|
|
||||||
|
// Wayfern.setFingerprint echoes back the fingerprint the browser actually
|
||||||
|
// applied, which may be UPGRADED from the stored one (e.g. when the
|
||||||
|
// stored fingerprint targets an older browser version). Persist it so the
|
||||||
|
// next launch starts from the upgraded value — saved below via
|
||||||
|
// save_process_info(&updated_profile).
|
||||||
|
if let Some(used_fp) = wayfern_result.used_fingerprint.clone() {
|
||||||
|
let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default();
|
||||||
|
if cfg.fingerprint.as_deref() != Some(used_fp.as_str()) {
|
||||||
|
log::info!(
|
||||||
|
"Persisting upgraded fingerprint from Wayfern.setFingerprint for profile: {} (len {})",
|
||||||
|
profile.name,
|
||||||
|
used_fp.len()
|
||||||
|
);
|
||||||
|
cfg.fingerprint = Some(used_fp);
|
||||||
|
updated_profile.wayfern_config = Some(cfg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update profile with the process info
|
// Update profile with the process info
|
||||||
updated_profile.process_id = Some(process_id);
|
updated_profile.process_id = Some(process_id);
|
||||||
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
|
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
|
||||||
@@ -935,57 +855,19 @@ impl BrowserRunner {
|
|||||||
remote_debugging_port: Option<u16>,
|
remote_debugging_port: Option<u16>,
|
||||||
headless: bool,
|
headless: bool,
|
||||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
// Always start a local proxy for API launches
|
// Camoufox and Wayfern start (and PID-reconcile) their own local proxy
|
||||||
let upstream_proxy = self
|
// inside `launch_browser_internal`, so we hand it None here rather than
|
||||||
.resolve_launch_proxy(profile)
|
// staging a second, orphaned proxy worker.
|
||||||
.await
|
self
|
||||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
|
||||||
|
|
||||||
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
|
|
||||||
let temp_pid = 1u32;
|
|
||||||
let profile_id_str = profile.id.to_string();
|
|
||||||
|
|
||||||
// Start local proxy - if this fails, DO NOT launch browser
|
|
||||||
let blocklist_file = Self::resolve_blocklist_file(profile)
|
|
||||||
.await
|
|
||||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
|
||||||
let internal_proxy = PROXY_MANAGER
|
|
||||||
.start_proxy(
|
|
||||||
app_handle.clone(),
|
|
||||||
upstream_proxy.as_ref(),
|
|
||||||
temp_pid,
|
|
||||||
Some(&profile_id_str),
|
|
||||||
profile.proxy_bypass_rules.clone(),
|
|
||||||
blocklist_file,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
let error_msg = format!("Failed to start local proxy: {e}");
|
|
||||||
log::error!("{}", error_msg);
|
|
||||||
error_msg
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let internal_proxy_settings = Some(internal_proxy.clone());
|
|
||||||
|
|
||||||
let result = self
|
|
||||||
.launch_browser_internal(
|
.launch_browser_internal(
|
||||||
app_handle.clone(),
|
app_handle,
|
||||||
profile,
|
profile,
|
||||||
url,
|
url,
|
||||||
internal_proxy_settings.as_ref(),
|
None,
|
||||||
remote_debugging_port,
|
remote_debugging_port,
|
||||||
headless,
|
headless,
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
|
|
||||||
// Update proxy with correct PID if launch succeeded
|
|
||||||
if let Ok(ref updated_profile) = result {
|
|
||||||
if let Some(actual_pid) = updated_profile.process_id {
|
|
||||||
let _ = PROXY_MANAGER.update_proxy_pid(temp_pid, actual_pid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn launch_or_open_url(
|
pub async fn launch_or_open_url(
|
||||||
@@ -2395,6 +2277,17 @@ pub async fn launch_browser_profile(
|
|||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
profile: BrowserProfile,
|
profile: BrowserProfile,
|
||||||
url: Option<String>,
|
url: Option<String>,
|
||||||
|
) -> Result<BrowserProfile, String> {
|
||||||
|
launch_browser_profile_impl(app_handle, profile, url, None, false, false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn launch_browser_profile_impl(
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
profile: BrowserProfile,
|
||||||
|
url: Option<String>,
|
||||||
|
remote_debugging_port: Option<u16>,
|
||||||
|
headless: bool,
|
||||||
|
force_new: bool,
|
||||||
) -> Result<BrowserProfile, String> {
|
) -> Result<BrowserProfile, String> {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Launch request received for profile: {} (ID: {})",
|
"Launch request received for profile: {} (ID: {})",
|
||||||
@@ -2424,9 +2317,6 @@ pub async fn launch_browser_profile(
|
|||||||
|
|
||||||
let browser_runner = BrowserRunner::instance();
|
let browser_runner = BrowserRunner::instance();
|
||||||
|
|
||||||
// Store the internal proxy settings for passing to launch_browser
|
|
||||||
let mut internal_proxy_settings: Option<ProxySettings> = None;
|
|
||||||
|
|
||||||
// Resolve the most up-to-date profile from disk by ID to avoid using stale proxy_id/browser state
|
// Resolve the most up-to-date profile from disk by ID to avoid using stale proxy_id/browser state
|
||||||
let profile_for_launch = match browser_runner
|
let profile_for_launch = match browser_runner
|
||||||
.profile_manager
|
.profile_manager
|
||||||
@@ -2448,112 +2338,36 @@ pub async fn launch_browser_profile(
|
|||||||
profile_for_launch.id
|
profile_for_launch.id
|
||||||
);
|
);
|
||||||
|
|
||||||
// Always start a local proxy before launching (non-Camoufox/Wayfern handled here; they have their own flow)
|
|
||||||
// This ensures all traffic goes through the local proxy for monitoring and future features
|
|
||||||
if profile.browser != "camoufox" && profile.browser != "wayfern" {
|
|
||||||
// Determine upstream proxy if configured; otherwise use DIRECT (no upstream)
|
|
||||||
// Refresh cloud proxy credentials and inject profile-specific sid
|
|
||||||
let mut upstream_proxy = BrowserRunner::instance()
|
|
||||||
.resolve_launch_proxy(&profile_for_launch)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
|
|
||||||
if upstream_proxy.is_none() {
|
|
||||||
if let Some(ref vpn_id) = profile_for_launch.vpn_id {
|
|
||||||
match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await {
|
|
||||||
Ok(vpn_worker) => {
|
|
||||||
if let Some(port) = vpn_worker.local_port {
|
|
||||||
upstream_proxy = Some(ProxySettings {
|
|
||||||
proxy_type: "socks5".to_string(),
|
|
||||||
host: "127.0.0.1".to_string(),
|
|
||||||
port,
|
|
||||||
username: None,
|
|
||||||
password: None,
|
|
||||||
});
|
|
||||||
log::info!("VPN worker started for profile on port {}", port);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
return Err(format!("Failed to start VPN worker: {e}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
|
|
||||||
let temp_pid = 1u32;
|
|
||||||
let profile_id_str = profile.id.to_string();
|
|
||||||
|
|
||||||
// Always start a local proxy, even if there's no upstream proxy
|
|
||||||
// This allows for traffic monitoring and future features
|
|
||||||
let blocklist_file = BrowserRunner::resolve_blocklist_file(&profile_for_launch).await?;
|
|
||||||
match PROXY_MANAGER
|
|
||||||
.start_proxy(
|
|
||||||
app_handle.clone(),
|
|
||||||
upstream_proxy.as_ref(),
|
|
||||||
temp_pid,
|
|
||||||
Some(&profile_id_str),
|
|
||||||
profile_for_launch.proxy_bypass_rules.clone(),
|
|
||||||
blocklist_file,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(internal_proxy) => {
|
|
||||||
// Use internal proxy for subsequent launch
|
|
||||||
internal_proxy_settings = Some(internal_proxy.clone());
|
|
||||||
|
|
||||||
// For Firefox-based browsers, always apply PAC/user.js to point to the local proxy
|
|
||||||
if matches!(
|
|
||||||
profile_for_launch.browser.as_str(),
|
|
||||||
"firefox" | "firefox-developer" | "zen"
|
|
||||||
) {
|
|
||||||
let profiles_dir = browser_runner.profile_manager.get_profiles_dir();
|
|
||||||
let profile_path = profiles_dir
|
|
||||||
.join(profile_for_launch.id.to_string())
|
|
||||||
.join("profile");
|
|
||||||
|
|
||||||
// Provide a dummy upstream (ignored when internal proxy is provided)
|
|
||||||
let dummy_upstream = ProxySettings {
|
|
||||||
proxy_type: "http".to_string(),
|
|
||||||
host: "127.0.0.1".to_string(),
|
|
||||||
port: internal_proxy.port,
|
|
||||||
username: None,
|
|
||||||
password: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
browser_runner
|
|
||||||
.profile_manager
|
|
||||||
.apply_proxy_settings_to_profile(&profile_path, &dummy_upstream, Some(&internal_proxy))
|
|
||||||
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"Local proxy prepared for profile: {} on port: {} (upstream: {})",
|
|
||||||
profile_for_launch.name,
|
|
||||||
internal_proxy.port,
|
|
||||||
upstream_proxy
|
|
||||||
.as_ref()
|
|
||||||
.map(|p| format!("{}:{}", p.host, p.port))
|
|
||||||
.unwrap_or_else(|| "DIRECT".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let error_msg = format!("Failed to start local proxy: {e}");
|
|
||||||
log::error!("{}", error_msg);
|
|
||||||
// DO NOT launch browser if proxy startup fails - all browsers must use local proxy
|
|
||||||
return Err(error_msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Starting browser launch for profile: {} (ID: {})",
|
"Starting browser launch for profile: {} (ID: {})",
|
||||||
profile_for_launch.name,
|
profile_for_launch.name,
|
||||||
profile_for_launch.id
|
profile_for_launch.id
|
||||||
);
|
);
|
||||||
|
|
||||||
// Launch browser or open URL in existing instance
|
// Launch browser or open URL in existing instance. Camoufox and Wayfern
|
||||||
let updated_profile = browser_runner.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, internal_proxy_settings.as_ref()).await.map_err(|e| {
|
// start their own local proxies inside `launch_browser_internal`; any
|
||||||
|
// other browser type is rejected there (we only support those for import,
|
||||||
|
// not launch), so no proxy needs to be staged here.
|
||||||
|
//
|
||||||
|
// `force_new` callers (API/MCP) always start a fresh instance with the
|
||||||
|
// requested debug port and headless mode, bypassing the "open URL in the
|
||||||
|
// existing window" path which would otherwise ignore both.
|
||||||
|
let launch_result = if force_new {
|
||||||
|
browser_runner
|
||||||
|
.launch_browser_with_debugging(
|
||||||
|
app_handle.clone(),
|
||||||
|
&profile_for_launch,
|
||||||
|
url,
|
||||||
|
remote_debugging_port,
|
||||||
|
headless,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
browser_runner
|
||||||
|
.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, None)
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
let updated_profile = launch_result.map_err(|e| {
|
||||||
log::info!("Browser launch failed for profile: {}, error: {}", profile_for_launch.name, e);
|
log::info!("Browser launch failed for profile: {}, error: {}", profile_for_launch.name, e);
|
||||||
|
|
||||||
// Emit a failure event to clear loading states in the frontend
|
// Emit a failure event to clear loading states in the frontend
|
||||||
@@ -2710,28 +2524,6 @@ pub async fn kill_browser_profile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn launch_browser_profile_with_debugging(
|
|
||||||
app_handle: tauri::AppHandle,
|
|
||||||
profile: BrowserProfile,
|
|
||||||
url: Option<String>,
|
|
||||||
remote_debugging_port: Option<u16>,
|
|
||||||
headless: bool,
|
|
||||||
) -> Result<BrowserProfile, String> {
|
|
||||||
if profile.is_cross_os() {
|
|
||||||
return Err(format!(
|
|
||||||
"Cannot launch profile '{}': this profile was created on {} and cannot be launched on a different operating system",
|
|
||||||
profile.name,
|
|
||||||
profile.host_os.as_deref().unwrap_or("another OS"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let browser_runner = BrowserRunner::instance();
|
|
||||||
browser_runner
|
|
||||||
.launch_browser_with_debugging(app_handle, &profile, url, remote_debugging_port, headless)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to launch browser with debugging: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_url_with_profile(
|
pub async fn open_url_with_profile(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ impl CamoufoxManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Launch Camoufox browser by directly spawning the process
|
/// Launch Camoufox browser by directly spawning the process
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn launch_camoufox(
|
pub async fn launch_camoufox(
|
||||||
&self,
|
&self,
|
||||||
_app_handle: &AppHandle,
|
_app_handle: &AppHandle,
|
||||||
@@ -207,6 +208,7 @@ impl CamoufoxManager {
|
|||||||
profile_path: &str,
|
profile_path: &str,
|
||||||
config: &CamoufoxConfig,
|
config: &CamoufoxConfig,
|
||||||
url: Option<&str>,
|
url: Option<&str>,
|
||||||
|
remote_debugging_port: Option<u16>,
|
||||||
headless: bool,
|
headless: bool,
|
||||||
) -> Result<CamoufoxLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<CamoufoxLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let custom_config = if let Some(existing_fingerprint) = &config.fingerprint {
|
let custom_config = if let Some(existing_fingerprint) = &config.fingerprint {
|
||||||
@@ -249,7 +251,10 @@ impl CamoufoxManager {
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
let cdp_port = Self::find_free_port().await?;
|
let cdp_port = match remote_debugging_port {
|
||||||
|
Some(p) => p,
|
||||||
|
None => Self::find_free_port().await?,
|
||||||
|
};
|
||||||
args.push(format!("--remote-debugging-port={cdp_port}"));
|
args.push(format!("--remote-debugging-port={cdp_port}"));
|
||||||
|
|
||||||
// Add URL if provided
|
// Add URL if provided
|
||||||
@@ -270,13 +275,33 @@ impl CamoufoxManager {
|
|||||||
args
|
args
|
||||||
);
|
);
|
||||||
|
|
||||||
// Spawn the browser process
|
// Spawn the browser process. Camoufox prints NSS/PSM and proxy failures
|
||||||
|
// to stderr (e.g. cert errors, CONNECT failures) and the user otherwise
|
||||||
|
// sees only an opaque "Secure Connection Failed" page — capture stderr
|
||||||
|
// to a per-launch file so diagnostics survive without a TTY.
|
||||||
|
let stderr_log_path = std::env::temp_dir().join(format!("camoufox-stderr-{}.log", profile.id));
|
||||||
let mut command = TokioCommand::new(&executable_path);
|
let mut command = TokioCommand::new(&executable_path);
|
||||||
command
|
command
|
||||||
.args(&args)
|
.args(&args)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null());
|
||||||
.stderr(Stdio::null());
|
|
||||||
|
match std::fs::File::create(&stderr_log_path) {
|
||||||
|
Ok(file) => {
|
||||||
|
log::info!(
|
||||||
|
"Camoufox stderr will be logged to: {}",
|
||||||
|
stderr_log_path.display()
|
||||||
|
);
|
||||||
|
command.stderr(Stdio::from(file));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to open Camoufox stderr log {}: {e}",
|
||||||
|
stderr_log_path.display()
|
||||||
|
);
|
||||||
|
command.stderr(Stdio::null());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add environment variables
|
// Add environment variables
|
||||||
for (key, value) in &env_vars {
|
for (key, value) in &env_vars {
|
||||||
@@ -646,6 +671,7 @@ impl CamoufoxManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl CamoufoxManager {
|
impl CamoufoxManager {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn launch_camoufox_profile(
|
pub async fn launch_camoufox_profile(
|
||||||
&self,
|
&self,
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
@@ -653,6 +679,7 @@ impl CamoufoxManager {
|
|||||||
config: CamoufoxConfig,
|
config: CamoufoxConfig,
|
||||||
url: Option<String>,
|
url: Option<String>,
|
||||||
override_profile_path: Option<std::path::PathBuf>,
|
override_profile_path: Option<std::path::PathBuf>,
|
||||||
|
remote_debugging_port: Option<u16>,
|
||||||
headless: bool,
|
headless: bool,
|
||||||
) -> Result<CamoufoxLaunchResult, String> {
|
) -> Result<CamoufoxLaunchResult, String> {
|
||||||
// Get profile path
|
// Get profile path
|
||||||
@@ -708,6 +735,8 @@ impl CamoufoxManager {
|
|||||||
// re-emit so they never duplicate.
|
// re-emit so they never duplicate.
|
||||||
let managed_keys = [
|
let managed_keys = [
|
||||||
"network.proxy.",
|
"network.proxy.",
|
||||||
|
"network.http.http3.enable",
|
||||||
|
"network.http.http3.enabled",
|
||||||
"xpinstall.signatures.required",
|
"xpinstall.signatures.required",
|
||||||
"extensions.startupScanScopes",
|
"extensions.startupScanScopes",
|
||||||
"browser.sessionhistory.max_entries",
|
"browser.sessionhistory.max_entries",
|
||||||
@@ -741,6 +770,19 @@ impl CamoufoxManager {
|
|||||||
user_pref(\"extensions.startupScanScopes\", 1);\n",
|
user_pref(\"extensions.startupScanScopes\", 1);\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Disable HTTP/3 / QUIC. Camoufox always sits behind the local
|
||||||
|
// donut-proxy, and Firefox-150's QUIC stack bypasses configured HTTP
|
||||||
|
// proxies and goes direct UDP to the remote host. With an upstream
|
||||||
|
// proxy that's the only allowed egress, that traffic silently fails
|
||||||
|
// and pages won't load. (Chromium suppresses QUIC under a proxy on
|
||||||
|
// its own, so Wayfern doesn't need the equivalent toggle.) Both
|
||||||
|
// pref names are emitted because they've been renamed across FF
|
||||||
|
// versions and either could be the active one at runtime.
|
||||||
|
prefs.push_str(
|
||||||
|
"user_pref(\"network.http.http3.enable\", false);\n\
|
||||||
|
user_pref(\"network.http.http3.enabled\", false);\n",
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(proxy_str) = &config.proxy {
|
if let Some(proxy_str) = &config.proxy {
|
||||||
if let Ok(parsed) = url::Url::parse(proxy_str) {
|
if let Ok(parsed) = url::Url::parse(proxy_str) {
|
||||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||||
@@ -782,6 +824,7 @@ impl CamoufoxManager {
|
|||||||
&profile_path_str,
|
&profile_path_str,
|
||||||
&config,
|
&config,
|
||||||
url.as_deref(),
|
url.as_deref(),
|
||||||
|
remote_debugging_port,
|
||||||
headless,
|
headless,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
+211
-24
@@ -21,6 +21,76 @@ use crate::sync;
|
|||||||
pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com";
|
pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com";
|
||||||
pub const CLOUD_SYNC_URL: &str = "https://sync.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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CloudUser {
|
pub struct CloudUser {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -46,6 +116,36 @@ pub struct CloudUser {
|
|||||||
pub team_name: Option<String>,
|
pub team_name: Option<String>,
|
||||||
#[serde(rename = "teamRole", default)]
|
#[serde(rename = "teamRole", default)]
|
||||||
pub team_role: Option<String>,
|
pub team_role: Option<String>,
|
||||||
|
// This desktop session's position among the user's active devices, oldest
|
||||||
|
// first. Ordinal 1 is the primary device — the only one that can run browser
|
||||||
|
// automation. `default` keeps older login/state payloads (which lack these
|
||||||
|
// fields) deserializing cleanly.
|
||||||
|
#[serde(rename = "deviceOrdinal", default)]
|
||||||
|
pub device_ordinal: Option<i64>,
|
||||||
|
#[serde(rename = "deviceCount", default)]
|
||||||
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -413,7 +513,18 @@ impl CloudAuthManager {
|
|||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
return Err(format!("Login failed ({status}): {body}"));
|
// The backend returns { message, code, … } for 4xx (e.g. the 3-device
|
||||||
|
// limit or a temporary security block). Surface the human-readable
|
||||||
|
// message rather than the raw JSON so the sign-in screen is clear.
|
||||||
|
let message = serde_json::from_str::<serde_json::Value>(&body)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| {
|
||||||
|
v.get("message")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.map(std::string::ToString::to_string)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| format!("Login failed ({status})"));
|
||||||
|
return Err(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: DeviceCodeExchangeResponse = response
|
let result: DeviceCodeExchangeResponse = response
|
||||||
@@ -637,39 +748,83 @@ impl CloudAuthManager {
|
|||||||
state.is_some()
|
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;
|
let state = self.state.lock().await;
|
||||||
match &*state {
|
state.as_ref().map(|auth| auth.user.entitlements())
|
||||||
Some(auth) => {
|
}
|
||||||
auth.user.plan != "free"
|
|
||||||
&& (auth.user.subscription_status == "active"
|
/// Account is in a paid/active state. Used for the "any active plan" gates
|
||||||
|| auth.user.plan_period.as_deref() == Some("lifetime"))
|
/// (sync token, wayfern token); per-feature access uses the capability helpers.
|
||||||
}
|
pub async fn has_active_paid_subscription(&self) -> bool {
|
||||||
None => false,
|
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.
|
/// 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 {
|
pub fn has_active_paid_subscription_sync(&self) -> bool {
|
||||||
match self.state.try_lock() {
|
match self.state.try_lock() {
|
||||||
Ok(state) => match &*state {
|
Ok(state) => state
|
||||||
Some(auth) => {
|
.as_ref()
|
||||||
auth.user.plan != "free"
|
.map(|auth| auth.user.entitlements().active)
|
||||||
&& (auth.user.subscription_status == "active"
|
.unwrap_or(false),
|
||||||
|| auth.user.plan_period.as_deref() == Some("lifetime"))
|
|
||||||
}
|
|
||||||
None => false,
|
|
||||||
},
|
|
||||||
Err(_) => 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 {
|
pub async fn is_fingerprint_os_allowed(&self, fingerprint_os: Option<&str>) -> bool {
|
||||||
let host_os = crate::profile::types::get_host_os();
|
let host_os = crate::profile::types::get_host_os();
|
||||||
match fingerprint_os {
|
match fingerprint_os {
|
||||||
None => true,
|
None => true,
|
||||||
Some(os) if os == host_os => true,
|
Some(os) if os == host_os => true,
|
||||||
Some(_) => self.has_active_paid_subscription().await,
|
Some(_) => self.can_use_cross_os_fingerprints().await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -995,7 +1150,7 @@ impl CloudAuthManager {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let token = self
|
let result = self
|
||||||
.api_call_with_retry(|access_token| {
|
.api_call_with_retry(|access_token| {
|
||||||
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
|
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
|
||||||
// Bound the request: without a timeout, an unreachable
|
// Bound the request: without a timeout, an unreachable
|
||||||
@@ -1029,7 +1184,31 @@ impl CloudAuthManager {
|
|||||||
Ok(result.token)
|
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;
|
let mut wt = self.wayfern_token.lock().await;
|
||||||
*wt = Some(token);
|
*wt = Some(token);
|
||||||
@@ -1163,7 +1342,7 @@ pub async fn cloud_exchange_device_code(
|
|||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
code: String,
|
code: String,
|
||||||
) -> Result<CloudAuthState, 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;
|
let has_subscription = CLOUD_AUTH.has_active_paid_subscription().await;
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -1198,17 +1377,25 @@ pub async fn cloud_exchange_device_code(
|
|||||||
let _ = crate::events::emit_empty("cloud-auth-changed");
|
let _ = crate::events::emit_empty("cloud-auth-changed");
|
||||||
|
|
||||||
let _ = &app_handle;
|
let _ = &app_handle;
|
||||||
|
state.user.entitlements = Some(state.user.entitlements());
|
||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn cloud_get_user() -> Result<Option<CloudAuthState>, String> {
|
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]
|
#[tauri::command]
|
||||||
pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
|
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]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -1,351 +0,0 @@
|
|||||||
use directories::ProjectDirs;
|
|
||||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
||||||
use std::fs;
|
|
||||||
use std::io;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
fn get_daemon_path() -> Option<PathBuf> {
|
|
||||||
// First try to find the daemon binary in the same directory as the current executable
|
|
||||||
if let Ok(current_exe) = std::env::current_exe() {
|
|
||||||
let daemon_path = current_exe.parent()?.join(daemon_binary_name());
|
|
||||||
if daemon_path.exists() {
|
|
||||||
return Some(daemon_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try common installation paths
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
let paths = [
|
|
||||||
PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
|
|
||||||
dirs::home_dir()?.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
|
|
||||||
];
|
|
||||||
for path in paths {
|
|
||||||
if path.exists() {
|
|
||||||
return Some(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
let paths = [
|
|
||||||
dirs::data_local_dir()?.join("Donut Browser/donut-daemon.exe"),
|
|
||||||
PathBuf::from("C:\\Program Files\\Donut Browser\\donut-daemon.exe"),
|
|
||||||
];
|
|
||||||
for path in paths {
|
|
||||||
if path.exists() {
|
|
||||||
return Some(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
let paths = [
|
|
||||||
PathBuf::from("/usr/bin/donut-daemon"),
|
|
||||||
PathBuf::from("/usr/local/bin/donut-daemon"),
|
|
||||||
dirs::home_dir()?.join(".local/bin/donut-daemon"),
|
|
||||||
];
|
|
||||||
for path in paths {
|
|
||||||
if path.exists() {
|
|
||||||
return Some(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn daemon_binary_name() -> &'static str {
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
"donut-daemon.exe"
|
|
||||||
}
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
{
|
|
||||||
"donut-daemon"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pub fn enable_autostart() -> io::Result<()> {
|
|
||||||
let daemon_path = get_daemon_path()
|
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
|
|
||||||
|
|
||||||
let plist_dir = dirs::home_dir()
|
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?
|
|
||||||
.join("Library/LaunchAgents");
|
|
||||||
|
|
||||||
fs::create_dir_all(&plist_dir)?;
|
|
||||||
|
|
||||||
let plist_path = plist_dir.join("com.donutbrowser.daemon.plist");
|
|
||||||
|
|
||||||
// Get log directory (use data directory instead of /tmp)
|
|
||||||
let log_dir = get_data_dir()
|
|
||||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
|
||||||
.join("logs");
|
|
||||||
fs::create_dir_all(&log_dir)?;
|
|
||||||
|
|
||||||
let plist_content = format!(
|
|
||||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>com.donutbrowser.daemon</string>
|
|
||||||
<key>ProgramArguments</key>
|
|
||||||
<array>
|
|
||||||
<string>{daemon_path}</string>
|
|
||||||
<string>run</string>
|
|
||||||
</array>
|
|
||||||
<key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
<key>LimitLoadToSessionType</key>
|
|
||||||
<string>Aqua</string>
|
|
||||||
<key>ProcessType</key>
|
|
||||||
<string>Interactive</string>
|
|
||||||
<key>StandardOutPath</key>
|
|
||||||
<string>{log_dir}/daemon.out.log</string>
|
|
||||||
<key>StandardErrorPath</key>
|
|
||||||
<string>{log_dir}/daemon.err.log</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
"#,
|
|
||||||
daemon_path = daemon_path.display(),
|
|
||||||
log_dir = log_dir.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
fs::write(&plist_path, plist_content)?;
|
|
||||||
|
|
||||||
log::info!("Created launch agent at {:?}", plist_path);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pub fn get_plist_path() -> Option<PathBuf> {
|
|
||||||
dirs::home_dir().map(|h| h.join("Library/LaunchAgents/com.donutbrowser.daemon.plist"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pub fn disable_autostart() -> io::Result<()> {
|
|
||||||
let plist_path = get_plist_path()
|
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Home directory not found"))?;
|
|
||||||
|
|
||||||
if plist_path.exists() {
|
|
||||||
// First unload the launch agent if it's loaded
|
|
||||||
let _ = unload_launch_agent();
|
|
||||||
fs::remove_file(&plist_path)?;
|
|
||||||
log::info!("Removed launch agent at {:?}", plist_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pub fn is_autostart_enabled() -> bool {
|
|
||||||
get_plist_path().is_some_and(|p| p.exists())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pub fn load_launch_agent() -> io::Result<()> {
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
let plist_path = get_plist_path()
|
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Could not determine plist path"))?;
|
|
||||||
|
|
||||||
if !plist_path.exists() {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::NotFound,
|
|
||||||
"Launch agent plist does not exist",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use launchctl load to start the daemon via launchd
|
|
||||||
// The -w flag writes the "disabled" key to the override plist
|
|
||||||
let output = Command::new("launchctl")
|
|
||||||
.args(["load", "-w"])
|
|
||||||
.arg(&plist_path)
|
|
||||||
.output()?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
// "already loaded" is not an error condition for us
|
|
||||||
if !stderr.contains("already loaded") {
|
|
||||||
return Err(io::Error::other(format!(
|
|
||||||
"launchctl load failed: {}",
|
|
||||||
stderr
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("Loaded launch agent via launchctl");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pub fn start_launch_agent() -> io::Result<()> {
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
let output = Command::new("launchctl")
|
|
||||||
.args(["start", "com.donutbrowser.daemon"])
|
|
||||||
.output()?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
return Err(io::Error::other(format!(
|
|
||||||
"launchctl start failed: {}",
|
|
||||||
stderr
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("Started launch agent via launchctl");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pub fn unload_launch_agent() -> io::Result<()> {
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
let plist_path = get_plist_path()
|
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Could not determine plist path"))?;
|
|
||||||
|
|
||||||
if !plist_path.exists() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = Command::new("launchctl")
|
|
||||||
.args(["unload"])
|
|
||||||
.arg(&plist_path)
|
|
||||||
.output()?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
// Not being loaded is not an error
|
|
||||||
if !stderr.contains("Could not find specified service") {
|
|
||||||
log::warn!("launchctl unload warning: {}", stderr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("Unloaded launch agent via launchctl");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub fn enable_autostart() -> io::Result<()> {
|
|
||||||
let daemon_path = get_daemon_path()
|
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
|
|
||||||
|
|
||||||
let autostart_dir = dirs::config_dir()
|
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))?
|
|
||||||
.join("autostart");
|
|
||||||
|
|
||||||
fs::create_dir_all(&autostart_dir)?;
|
|
||||||
|
|
||||||
let desktop_path = autostart_dir.join("donut-daemon.desktop");
|
|
||||||
|
|
||||||
let escaped_daemon_path = daemon_path
|
|
||||||
.display()
|
|
||||||
.to_string()
|
|
||||||
.replace('\\', "\\\\")
|
|
||||||
.replace('"', "\\\"")
|
|
||||||
.replace('`', "\\`")
|
|
||||||
.replace('$', "\\$");
|
|
||||||
let desktop_content = format!(
|
|
||||||
r#"[Desktop Entry]
|
|
||||||
Type=Application
|
|
||||||
Name=Donut Browser Daemon
|
|
||||||
Exec="{escaped_daemon_path}" run
|
|
||||||
Hidden=false
|
|
||||||
NoDisplay=true
|
|
||||||
X-GNOME-Autostart-enabled=true
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
fs::write(&desktop_path, desktop_content)?;
|
|
||||||
|
|
||||||
log::info!("Created autostart entry at {:?}", desktop_path);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub fn disable_autostart() -> io::Result<()> {
|
|
||||||
let desktop_path = dirs::config_dir()
|
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))?
|
|
||||||
.join("autostart/donut-daemon.desktop");
|
|
||||||
|
|
||||||
if desktop_path.exists() {
|
|
||||||
fs::remove_file(&desktop_path)?;
|
|
||||||
log::info!("Removed autostart entry at {:?}", desktop_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub fn is_autostart_enabled() -> bool {
|
|
||||||
dirs::config_dir()
|
|
||||||
.map(|c| c.join("autostart/donut-daemon.desktop").exists())
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
pub fn enable_autostart() -> io::Result<()> {
|
|
||||||
use winreg::enums::HKEY_CURRENT_USER;
|
|
||||||
use winreg::RegKey;
|
|
||||||
|
|
||||||
let daemon_path = get_daemon_path()
|
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Daemon binary not found"))?;
|
|
||||||
|
|
||||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
|
||||||
let (key, _) = hkcu.create_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run")?;
|
|
||||||
|
|
||||||
key.set_value(
|
|
||||||
"DonutBrowserDaemon",
|
|
||||||
&format!("\"{}\" run", daemon_path.display()),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
log::info!("Added registry autostart entry");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
pub fn disable_autostart() -> io::Result<()> {
|
|
||||||
use winreg::enums::HKEY_CURRENT_USER;
|
|
||||||
use winreg::RegKey;
|
|
||||||
|
|
||||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
|
||||||
if let Ok(key) = hkcu.open_subkey_with_flags(
|
|
||||||
"Software\\Microsoft\\Windows\\CurrentVersion\\Run",
|
|
||||||
winreg::enums::KEY_WRITE,
|
|
||||||
) {
|
|
||||||
let _ = key.delete_value("DonutBrowserDaemon");
|
|
||||||
log::info!("Removed registry autostart entry");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
pub fn is_autostart_enabled() -> bool {
|
|
||||||
use winreg::enums::HKEY_CURRENT_USER;
|
|
||||||
use winreg::RegKey;
|
|
||||||
|
|
||||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
|
||||||
if let Ok(key) = hkcu.open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Run") {
|
|
||||||
key.get_value::<String, _>("DonutBrowserDaemon").is_ok()
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_data_dir() -> Option<PathBuf> {
|
|
||||||
if crate::app_dirs::is_portable() {
|
|
||||||
return Some(crate::app_dirs::data_dir());
|
|
||||||
}
|
|
||||||
if let Some(proj_dirs) = ProjectDirs::from("com", "donutbrowser", "Donut Browser") {
|
|
||||||
Some(proj_dirs.data_dir().to_path_buf())
|
|
||||||
} else {
|
|
||||||
dirs::home_dir().map(|h| h.join(".donutbrowser"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
pub mod autostart;
|
|
||||||
pub mod services;
|
|
||||||
pub mod tray;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
use crate::events::{self, DaemonEmitter, DaemonEvent};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::broadcast;
|
|
||||||
|
|
||||||
pub struct DaemonServices {
|
|
||||||
pub api_port: Option<u16>,
|
|
||||||
pub mcp_running: bool,
|
|
||||||
event_emitter: Arc<DaemonEmitter>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DaemonServices {
|
|
||||||
pub async fn start() -> Result<Self, String> {
|
|
||||||
log::info!("Starting daemon services...");
|
|
||||||
|
|
||||||
// Create the daemon event emitter
|
|
||||||
let (emitter, _rx) = DaemonEmitter::with_capacity(256);
|
|
||||||
let emitter_arc = Arc::new(emitter);
|
|
||||||
|
|
||||||
// Set the global event emitter
|
|
||||||
if let Err(e) = events::set_global_emitter(emitter_arc.clone()) {
|
|
||||||
log::warn!("Failed to set global event emitter: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: The API server currently requires an AppHandle which is only available
|
|
||||||
// in the Tauri GUI context. For now, the daemon starts with minimal services.
|
|
||||||
// The GUI will start the API server when it connects to the daemon.
|
|
||||||
//
|
|
||||||
// TODO: Refactor API server to work without AppHandle for daemon mode
|
|
||||||
let api_port = None;
|
|
||||||
let mcp_running = false;
|
|
||||||
|
|
||||||
log::info!("Daemon services started (minimal mode - waiting for GUI connection)");
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
api_port,
|
|
||||||
mcp_running,
|
|
||||||
event_emitter: emitter_arc,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn subscribe_events(&self) -> broadcast::Receiver<DaemonEvent> {
|
|
||||||
self.event_emitter.subscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn stop(&mut self) {
|
|
||||||
log::info!("Stopping daemon services...");
|
|
||||||
|
|
||||||
self.api_port = None;
|
|
||||||
self.mcp_running = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
use std::process::Command;
|
|
||||||
use tray_icon::menu::{Menu, MenuItem};
|
|
||||||
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
|
|
||||||
|
|
||||||
pub fn load_icon() -> Icon {
|
|
||||||
// On Windows, use the full-color icon so it renders well on dark taskbars.
|
|
||||||
// On macOS/Linux, use the template icon (black with alpha) for system light/dark handling.
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
let icon_bytes = include_bytes!("../../icons/tray-icon-win-44.png");
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
let icon_bytes = include_bytes!("../../icons/tray-icon-44.png");
|
|
||||||
|
|
||||||
let image = image::load_from_memory(icon_bytes)
|
|
||||||
.expect("Failed to load icon")
|
|
||||||
.into_rgba8();
|
|
||||||
|
|
||||||
let (width, height) = image.dimensions();
|
|
||||||
let rgba = image.into_raw();
|
|
||||||
|
|
||||||
Icon::from_rgba(rgba, width, height).expect("Failed to create icon")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct TrayMenu {
|
|
||||||
pub menu: Menu,
|
|
||||||
pub quit_item: MenuItem,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for TrayMenu {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TrayMenu {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let menu = Menu::new();
|
|
||||||
|
|
||||||
let quit_item = MenuItem::new("Quit Donut Browser", true, None);
|
|
||||||
|
|
||||||
menu.append(&quit_item).unwrap();
|
|
||||||
|
|
||||||
Self { menu, quit_item }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_tray_icon(icon: Icon, menu: &Menu) -> TrayIcon {
|
|
||||||
let builder = TrayIconBuilder::new()
|
|
||||||
.with_icon(icon)
|
|
||||||
.with_tooltip("Donut Browser")
|
|
||||||
.with_menu(Box::new(menu.clone()));
|
|
||||||
|
|
||||||
// On macOS, template icons are automatically colored by the system for light/dark mode
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
let builder = builder.with_icon_as_template(true);
|
|
||||||
|
|
||||||
builder.build().expect("Failed to create tray icon")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve the .app bundle path from the current daemon executable.
|
|
||||||
/// In production the daemon is at `Donut.app/Contents/MacOS/donut-daemon`.
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn get_app_bundle_path() -> Option<std::path::PathBuf> {
|
|
||||||
let exe = std::env::current_exe().ok()?;
|
|
||||||
let macos_dir = exe.parent()?;
|
|
||||||
let contents_dir = macos_dir.parent()?;
|
|
||||||
let app_dir = contents_dir.parent()?;
|
|
||||||
if app_dir.extension().and_then(|e| e.to_str()) == Some("app") {
|
|
||||||
Some(app_dir.to_path_buf())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open_gui() {
|
|
||||||
log::info!("Opening GUI...");
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
// Launch the GUI binary directly. The daemon lives inside the same .app
|
|
||||||
// bundle, so `open` (even with `-n`) can re-activate the daemon instead
|
|
||||||
// of launching the GUI. Directly running the binary avoids macOS's app
|
|
||||||
// activation machinery. The single-instance Tauri plugin in the GUI
|
|
||||||
// handles deduplication if a GUI instance is already running.
|
|
||||||
if let Some(app_bundle) = get_app_bundle_path() {
|
|
||||||
let gui_binary = app_bundle.join("Contents").join("MacOS").join("Donut");
|
|
||||||
if gui_binary.exists() {
|
|
||||||
let _ = Command::new(&gui_binary).spawn();
|
|
||||||
} else {
|
|
||||||
let _ = Command::new("open").args(["-n"]).arg(&app_bundle).spawn();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let _ = Command::new("open").args(["-n", "-a", "Donut"]).spawn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
if let Ok(current_exe) = std::env::current_exe() {
|
|
||||||
if let Some(exe_dir) = current_exe.parent() {
|
|
||||||
let app_path = exe_dir.join("donutbrowser.exe");
|
|
||||||
if app_path.exists() {
|
|
||||||
let _ = Command::new(app_path).spawn();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let paths = [
|
|
||||||
dirs::data_local_dir().map(|p| p.join("Donut Browser").join("Donut Browser.exe")),
|
|
||||||
Some(PathBuf::from(
|
|
||||||
"C:\\Program Files\\Donut Browser\\Donut Browser.exe",
|
|
||||||
)),
|
|
||||||
];
|
|
||||||
|
|
||||||
for path in paths.iter().flatten() {
|
|
||||||
if path.exists() {
|
|
||||||
let _ = Command::new(path).spawn();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
let _ = Command::new("donutbrowser").spawn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_gui_pid() -> Option<u32> {
|
|
||||||
let path = super::autostart::get_data_dir()?.join("daemon-state.json");
|
|
||||||
let content = std::fs::read_to_string(path).ok()?;
|
|
||||||
let val: serde_json::Value = serde_json::from_str(&content).ok()?;
|
|
||||||
val.get("gui_pid")?.as_u64().map(|p| p as u32)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn kill_gui_by_pid() -> bool {
|
|
||||||
let Some(pid) = read_gui_pid() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
let ret = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
|
|
||||||
ret == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
||||||
Command::new("taskkill")
|
|
||||||
.args(["/PID", &pid.to_string(), "/F"])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.output()
|
|
||||||
.map(|o| o.status.success())
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(any(unix, windows)))]
|
|
||||||
{
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn quit_gui() {
|
|
||||||
log::info!("[daemon] Quitting GUI...");
|
|
||||||
|
|
||||||
if kill_gui_by_pid() {
|
|
||||||
log::info!("[daemon] GUI killed by PID");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("[daemon] PID-based kill failed, falling back to name-based kill");
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
// Use spawn() instead of output() to avoid blocking the event loop.
|
|
||||||
// AppleScript has a ~2 minute default timeout that would freeze the tray icon.
|
|
||||||
let _ = Command::new("osascript")
|
|
||||||
.args(["-e", "tell application \"Donut\" to quit"])
|
|
||||||
.spawn();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
||||||
let _ = Command::new("taskkill")
|
|
||||||
.args(["/IM", "Donut.exe", "/F"])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.spawn();
|
|
||||||
let _ = Command::new("taskkill")
|
|
||||||
.args(["/IM", "donutbrowser.exe", "/F"])
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.spawn();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
let _ = Command::new("pkill").args(["-x", "donutbrowser"]).spawn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
use futures_util::{SinkExt, StreamExt};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tauri::Emitter;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct WsMessage {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub msg_type: String,
|
|
||||||
pub event: Option<String>,
|
|
||||||
pub payload: Option<serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct DaemonClient {
|
|
||||||
app_handle: tauri::AppHandle,
|
|
||||||
connected: Arc<AtomicBool>,
|
|
||||||
shutdown: Arc<AtomicBool>,
|
|
||||||
daemon_port: Arc<Mutex<Option<u16>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DaemonClient {
|
|
||||||
pub fn new(app_handle: tauri::AppHandle) -> Self {
|
|
||||||
Self {
|
|
||||||
app_handle,
|
|
||||||
connected: Arc::new(AtomicBool::new(false)),
|
|
||||||
shutdown: Arc::new(AtomicBool::new(false)),
|
|
||||||
daemon_port: Arc::new(Mutex::new(None)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_connected(&self) -> bool {
|
|
||||||
self.connected.load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn connect(&self, port: u16) -> Result<(), String> {
|
|
||||||
*self.daemon_port.lock().await = Some(port);
|
|
||||||
|
|
||||||
let url = format!("ws://127.0.0.1:{}/ws/events", port);
|
|
||||||
|
|
||||||
log::info!("[daemon-client] Connecting to daemon at {}", url);
|
|
||||||
|
|
||||||
let (ws_stream, _) = connect_async(&url)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to connect to daemon: {}", e))?;
|
|
||||||
|
|
||||||
self.connected.store(true, Ordering::SeqCst);
|
|
||||||
log::info!("[daemon-client] Connected to daemon");
|
|
||||||
|
|
||||||
let (mut write, mut read) = ws_stream.split();
|
|
||||||
|
|
||||||
let app_handle = self.app_handle.clone();
|
|
||||||
let connected = self.connected.clone();
|
|
||||||
let shutdown = self.shutdown.clone();
|
|
||||||
|
|
||||||
// Spawn task to handle incoming messages
|
|
||||||
tokio::spawn(async move {
|
|
||||||
while !shutdown.load(Ordering::SeqCst) {
|
|
||||||
match read.next().await {
|
|
||||||
Some(Ok(Message::Text(text))) => {
|
|
||||||
if let Ok(ws_msg) = serde_json::from_str::<WsMessage>(&text) {
|
|
||||||
match ws_msg.msg_type.as_str() {
|
|
||||||
"event" => {
|
|
||||||
if let (Some(event), Some(payload)) = (ws_msg.event, ws_msg.payload) {
|
|
||||||
// Forward event to Tauri frontend
|
|
||||||
if let Err(e) = app_handle.emit(&event, payload) {
|
|
||||||
log::error!("[daemon-client] Failed to emit event: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"connected" => {
|
|
||||||
log::info!("[daemon-client] Received connection confirmation");
|
|
||||||
}
|
|
||||||
"pong" => {
|
|
||||||
log::debug!("[daemon-client] Received pong");
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
log::debug!("[daemon-client] Unknown message type: {}", ws_msg.msg_type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Ok(Message::Ping(data))) => {
|
|
||||||
log::debug!("[daemon-client] Received ping");
|
|
||||||
if let Err(e) = write.send(Message::Pong(data)).await {
|
|
||||||
log::error!("[daemon-client] Failed to send pong: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Ok(Message::Close(_))) => {
|
|
||||||
log::info!("[daemon-client] Daemon closed connection");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Some(Err(e)) => {
|
|
||||||
log::error!("[daemon-client] WebSocket error: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
log::info!("[daemon-client] WebSocket stream ended");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
connected.store(false, Ordering::SeqCst);
|
|
||||||
log::info!("[daemon-client] Disconnected from daemon");
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn disconnect(&self) {
|
|
||||||
self.shutdown.store(true, Ordering::SeqCst);
|
|
||||||
self.connected.store(false, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn start_daemon_connection(app_handle: tauri::AppHandle, port: u16) -> DaemonClient {
|
|
||||||
let client = DaemonClient::new(app_handle);
|
|
||||||
|
|
||||||
if let Err(e) = client.connect(port).await {
|
|
||||||
log::error!("[daemon-client] Failed to connect: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
client
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn find_and_connect_to_daemon(app_handle: tauri::AppHandle) -> Option<DaemonClient> {
|
|
||||||
// Try default port first
|
|
||||||
let default_port = 10108;
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"[daemon-client] Looking for daemon on port {}",
|
|
||||||
default_port
|
|
||||||
);
|
|
||||||
|
|
||||||
let client = DaemonClient::new(app_handle);
|
|
||||||
|
|
||||||
match client.connect(default_port).await {
|
|
||||||
Ok(()) => Some(client),
|
|
||||||
Err(e) => {
|
|
||||||
log::warn!(
|
|
||||||
"[daemon-client] Could not connect to daemon on default port: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
// Daemon Spawn - Start the daemon from the GUI
|
|
||||||
// Currently disabled; will be re-enabled in the future
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
use std::thread;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crate::daemon::autostart;
|
|
||||||
|
|
||||||
/// Check if a process with the given PID exists using the Windows API.
|
|
||||||
/// This avoids spawning tasklist.exe which causes a visible conhost window flash.
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn win_process_exists(pid: u32) -> bool {
|
|
||||||
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
|
|
||||||
|
|
||||||
extern "system" {
|
|
||||||
fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut ();
|
|
||||||
fn CloseHandle(hObject: *mut ()) -> i32;
|
|
||||||
}
|
|
||||||
|
|
||||||
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
|
||||||
if handle.is_null() {
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
unsafe { CloseHandle(handle) };
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Default)]
|
|
||||||
struct DaemonState {
|
|
||||||
daemon_pid: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_state_path() -> PathBuf {
|
|
||||||
autostart::get_data_dir()
|
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
|
||||||
.join("daemon-state.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_state() -> DaemonState {
|
|
||||||
let path = get_state_path();
|
|
||||||
if path.exists() {
|
|
||||||
if let Ok(content) = fs::read_to_string(&path) {
|
|
||||||
if let Ok(state) = serde_json::from_str(&content) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DaemonState::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_daemon_running() -> bool {
|
|
||||||
let state = read_state();
|
|
||||||
|
|
||||||
if let Some(pid) = state.daemon_pid {
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
unsafe { libc::kill(pid as i32, 0) == 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
win_process_exists(pid)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(any(unix, windows)))]
|
|
||||||
{
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn is_dev_mode() -> bool {
|
|
||||||
if let Ok(current_exe) = std::env::current_exe() {
|
|
||||||
let path_str = current_exe.to_string_lossy();
|
|
||||||
path_str.contains("target/debug") || path_str.contains("target/release")
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn get_daemon_path() -> Option<PathBuf> {
|
|
||||||
// First try to find the daemon binary next to the current executable
|
|
||||||
if let Ok(current_exe) = std::env::current_exe() {
|
|
||||||
if let Some(exe_dir) = current_exe.parent() {
|
|
||||||
let daemon_path = exe_dir.join("donut-daemon");
|
|
||||||
if daemon_path.exists() {
|
|
||||||
return Some(daemon_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try common installation paths
|
|
||||||
let paths = [
|
|
||||||
PathBuf::from("/Applications/Donut Browser.app/Contents/MacOS/donut-daemon"),
|
|
||||||
dirs::home_dir()
|
|
||||||
.map(|h| h.join("Applications/Donut Browser.app/Contents/MacOS/donut-daemon"))
|
|
||||||
.unwrap_or_default(),
|
|
||||||
];
|
|
||||||
paths.into_iter().find(|path| path.exists())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", windows))]
|
|
||||||
fn get_daemon_path() -> Option<PathBuf> {
|
|
||||||
// First, try to find it next to the current executable
|
|
||||||
if let Ok(current_exe) = std::env::current_exe() {
|
|
||||||
let exe_dir = current_exe.parent()?;
|
|
||||||
|
|
||||||
// Check for daemon binary in same directory
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
let daemon_name = "donut-daemon.exe";
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
let daemon_name = "donut-daemon";
|
|
||||||
|
|
||||||
let daemon_path = exe_dir.join(daemon_name);
|
|
||||||
if daemon_path.exists() {
|
|
||||||
return Some(daemon_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to find it in PATH
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
||||||
if let Ok(output) = Command::new("where")
|
|
||||||
.arg("donut-daemon")
|
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
|
||||||
.output()
|
|
||||||
{
|
|
||||||
if output.status.success() {
|
|
||||||
let path = String::from_utf8_lossy(&output.stdout);
|
|
||||||
let path = path.lines().next()?.trim();
|
|
||||||
return Some(PathBuf::from(path));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
if let Ok(output) = Command::new("which").arg("donut-daemon").output() {
|
|
||||||
if output.status.success() {
|
|
||||||
let path = String::from_utf8_lossy(&output.stdout);
|
|
||||||
let path = path.trim();
|
|
||||||
if !path.is_empty() {
|
|
||||||
return Some(PathBuf::from(path));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn spawn_daemon() -> Result<(), String> {
|
|
||||||
// Log the daemon state for debugging
|
|
||||||
let state = read_state();
|
|
||||||
log::info!("Daemon state before spawn: pid={:?}", state.daemon_pid);
|
|
||||||
|
|
||||||
// Check if already running
|
|
||||||
if is_daemon_running() {
|
|
||||||
log::info!("Daemon is already running (verified by PID check)");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("Daemon is not running, attempting to start...");
|
|
||||||
|
|
||||||
// Log current exe location for debugging
|
|
||||||
let current_exe = std::env::current_exe().ok();
|
|
||||||
log::info!("Current exe: {:?}", current_exe);
|
|
||||||
|
|
||||||
// On macOS, use launchctl to start the daemon via launchd
|
|
||||||
// This ensures the daemon runs in the user's Aqua session with WindowServer access
|
|
||||||
// and survives app termination since it's managed by launchd, not as a child process
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
spawn_daemon_macos()?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// On Linux, use direct spawn
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
spawn_daemon_unix()?;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
spawn_daemon_windows()?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for daemon to start (max 3 seconds)
|
|
||||||
for i in 0..30 {
|
|
||||||
thread::sleep(Duration::from_millis(100));
|
|
||||||
if is_daemon_running() {
|
|
||||||
log::info!("Daemon started successfully after {}ms", (i + 1) * 100);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we got a state file at least
|
|
||||||
let state = read_state();
|
|
||||||
if let Some(pid) = state.daemon_pid {
|
|
||||||
log::info!("Daemon appears to have started (PID {} in state file)", pid);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
Err("Daemon did not start within timeout".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn spawn_daemon_macos() -> Result<(), String> {
|
|
||||||
use std::os::unix::process::CommandExt;
|
|
||||||
|
|
||||||
// In dev mode, use direct spawn instead of launchctl
|
|
||||||
// This avoids issues with plist paths pointing to wrong binaries
|
|
||||||
if is_dev_mode() {
|
|
||||||
log::info!("Dev mode detected, using direct spawn instead of launchctl");
|
|
||||||
|
|
||||||
let daemon_path = get_daemon_path().ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"Could not find daemon binary. Current exe: {:?}",
|
|
||||||
std::env::current_exe().ok()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
log::info!("Spawning daemon from: {:?}", daemon_path);
|
|
||||||
|
|
||||||
// Create a new process group so daemon survives parent exit
|
|
||||||
let mut cmd = Command::new(&daemon_path);
|
|
||||||
cmd
|
|
||||||
.arg("run")
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.process_group(0);
|
|
||||||
|
|
||||||
cmd
|
|
||||||
.spawn()
|
|
||||||
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
|
|
||||||
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Production mode: use launchctl for proper daemon management
|
|
||||||
// First, ensure the LaunchAgent plist is installed
|
|
||||||
let autostart_enabled = autostart::is_autostart_enabled();
|
|
||||||
log::info!("LaunchAgent plist exists: {}", autostart_enabled);
|
|
||||||
|
|
||||||
if !autostart_enabled {
|
|
||||||
log::info!("Installing LaunchAgent plist for daemon management");
|
|
||||||
autostart::enable_autostart().map_err(|e| format!("Failed to install LaunchAgent: {}", e))?;
|
|
||||||
log::info!("LaunchAgent plist installed successfully");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the launch agent via launchctl
|
|
||||||
log::info!("Loading daemon via launchctl...");
|
|
||||||
autostart::load_launch_agent().map_err(|e| format!("Failed to load LaunchAgent: {}", e))?;
|
|
||||||
log::info!("launchctl load completed");
|
|
||||||
|
|
||||||
// Also explicitly start the agent in case it was already loaded but stopped
|
|
||||||
if let Err(e) = autostart::start_launch_agent() {
|
|
||||||
log::debug!("launchctl start note (non-fatal): {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn spawn_daemon_unix() -> Result<(), String> {
|
|
||||||
use std::os::unix::process::CommandExt;
|
|
||||||
|
|
||||||
let daemon_path = get_daemon_path().ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"Could not find daemon binary. Current exe: {:?}",
|
|
||||||
std::env::current_exe().ok()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
log::info!("Spawning daemon from: {:?}", daemon_path);
|
|
||||||
|
|
||||||
// Create a new process group so daemon survives parent exit
|
|
||||||
let mut cmd = Command::new(&daemon_path);
|
|
||||||
cmd
|
|
||||||
.arg("run")
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.process_group(0);
|
|
||||||
|
|
||||||
cmd
|
|
||||||
.spawn()
|
|
||||||
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn spawn_daemon_windows() -> Result<(), String> {
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
|
||||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
|
||||||
|
|
||||||
let daemon_path = get_daemon_path().ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"Could not find daemon binary. Current exe: {:?}",
|
|
||||||
std::env::current_exe().ok()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
log::info!("Spawning daemon from: {:?}", daemon_path);
|
|
||||||
|
|
||||||
Command::new(&daemon_path)
|
|
||||||
.arg("run")
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
|
|
||||||
.spawn()
|
|
||||||
.map_err(|e| format!("Failed to spawn daemon: {}", e))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ensure_daemon_running() -> Result<(), String> {
|
|
||||||
if !is_daemon_running() {
|
|
||||||
spawn_daemon()?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn register_gui_pid() {
|
|
||||||
let path = get_state_path();
|
|
||||||
let mut val: serde_json::Value = if path.exists() {
|
|
||||||
fs::read_to_string(&path)
|
|
||||||
.ok()
|
|
||||||
.and_then(|c| serde_json::from_str(&c).ok())
|
|
||||||
.unwrap_or_else(|| serde_json::json!({}))
|
|
||||||
} else {
|
|
||||||
serde_json::json!({})
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(obj) = val.as_object_mut() {
|
|
||||||
obj.insert(
|
|
||||||
"gui_pid".to_string(),
|
|
||||||
serde_json::Value::Number(std::process::id().into()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(content) = serde_json::to_string_pretty(&val) {
|
|
||||||
let _ = fs::write(&path, content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
extract::{
|
|
||||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
|
||||||
State,
|
|
||||||
},
|
|
||||||
response::IntoResponse,
|
|
||||||
};
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::events::{DaemonEmitter, DaemonEvent};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct WsMessage {
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub msg_type: String,
|
|
||||||
pub event: Option<String>,
|
|
||||||
pub payload: Option<serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct WsState {
|
|
||||||
event_emitter: Option<Arc<DaemonEmitter>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WsState {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
event_emitter: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_emitter(emitter: Arc<DaemonEmitter>) -> Self {
|
|
||||||
Self {
|
|
||||||
event_emitter: Some(emitter),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for WsState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<WsState>) -> impl IntoResponse {
|
|
||||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_socket(socket: WebSocket, state: WsState) {
|
|
||||||
let (mut sender, mut receiver) = socket.split();
|
|
||||||
|
|
||||||
// Subscribe to daemon events if emitter is available
|
|
||||||
let mut event_rx = state.event_emitter.as_ref().map(|e| e.subscribe());
|
|
||||||
|
|
||||||
log::info!("[ws] Client connected");
|
|
||||||
|
|
||||||
// Send initial ping to confirm connection
|
|
||||||
let ping_msg = WsMessage {
|
|
||||||
msg_type: "connected".to_string(),
|
|
||||||
event: None,
|
|
||||||
payload: None,
|
|
||||||
};
|
|
||||||
if let Ok(msg_str) = serde_json::to_string(&ping_msg) {
|
|
||||||
let _ = sender.send(Message::Text(msg_str.into())).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
// Handle incoming messages from client
|
|
||||||
Some(msg) = receiver.next() => {
|
|
||||||
match msg {
|
|
||||||
Ok(Message::Text(text)) => {
|
|
||||||
if let Ok(ws_msg) = serde_json::from_str::<WsMessage>(&text) {
|
|
||||||
match ws_msg.msg_type.as_str() {
|
|
||||||
"ping" => {
|
|
||||||
let pong = WsMessage {
|
|
||||||
msg_type: "pong".to_string(),
|
|
||||||
event: None,
|
|
||||||
payload: None,
|
|
||||||
};
|
|
||||||
if let Ok(msg_str) = serde_json::to_string(&pong) {
|
|
||||||
let _ = sender.send(Message::Text(msg_str.into())).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
log::debug!("[ws] Received unknown message type: {}", ws_msg.msg_type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Message::Ping(data)) => {
|
|
||||||
let _ = sender.send(Message::Pong(data)).await;
|
|
||||||
}
|
|
||||||
Ok(Message::Close(_)) => {
|
|
||||||
log::info!("[ws] Client disconnected");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("[ws] Error receiving message: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward daemon events to client
|
|
||||||
Some(daemon_event) = async {
|
|
||||||
if let Some(ref mut rx) = event_rx {
|
|
||||||
rx.recv().await.ok()
|
|
||||||
} else {
|
|
||||||
std::future::pending::<Option<DaemonEvent>>().await
|
|
||||||
}
|
|
||||||
} => {
|
|
||||||
let ws_msg = WsMessage {
|
|
||||||
msg_type: "event".to_string(),
|
|
||||||
event: Some(daemon_event.event_type),
|
|
||||||
payload: Some(daemon_event.payload),
|
|
||||||
};
|
|
||||||
if let Ok(msg_str) = serde_json::to_string(&ws_msg) {
|
|
||||||
if sender.send(Message::Text(msg_str.into())).await.is_err() {
|
|
||||||
log::error!("[ws] Failed to send event to client");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("[ws] WebSocket connection closed");
|
|
||||||
}
|
|
||||||
@@ -1296,21 +1296,73 @@ pub async fn ensure_active_browsers_downloaded(
|
|||||||
};
|
};
|
||||||
|
|
||||||
log::info!("Auto-downloading {browser} {version} (no versions found locally)");
|
log::info!("Auto-downloading {browser} {version} (no versions found locally)");
|
||||||
match crate::downloader::download_browser(
|
|
||||||
app_handle.clone(),
|
// Retry transient failures a few times. Each attempt is wrapped in an overall
|
||||||
browser.to_string(),
|
// timeout so that a hang anywhere in the download pipeline (version resolution,
|
||||||
version.clone(),
|
// a stalled stream, extraction) cannot block the next browser forever. This is
|
||||||
)
|
// the core of the bug fix: Wayfern going first must never starve Camoufox.
|
||||||
.await
|
const MAX_ATTEMPTS: u32 = 3;
|
||||||
{
|
const ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
|
||||||
Ok(_) => {
|
let mut succeeded = false;
|
||||||
downloaded.push(format!("{browser} {version}"));
|
for attempt in 1..=MAX_ATTEMPTS {
|
||||||
log::info!("Successfully auto-downloaded {browser} {version}");
|
let result = tokio::time::timeout(
|
||||||
|
ATTEMPT_TIMEOUT,
|
||||||
|
crate::downloader::download_browser(
|
||||||
|
app_handle.clone(),
|
||||||
|
browser.to_string(),
|
||||||
|
version.clone(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Ok(_)) => {
|
||||||
|
downloaded.push(format!("{browser} {version}"));
|
||||||
|
log::info!("Successfully auto-downloaded {browser} {version}");
|
||||||
|
succeeded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to auto-download {browser} {version} (attempt {attempt}/{MAX_ATTEMPTS}): {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// The download future itself hung past the overall timeout and was dropped,
|
||||||
|
// so its own cleanup never ran. Clear any leftover in-progress bookkeeping
|
||||||
|
// (the future may have re-resolved to a different version, so clear by
|
||||||
|
// browser prefix) and emit a terminal error event so the UI stops spinning.
|
||||||
|
log::warn!(
|
||||||
|
"Auto-download of {browser} {version} timed out after {}s (attempt {attempt}/{MAX_ATTEMPTS})",
|
||||||
|
ATTEMPT_TIMEOUT.as_secs()
|
||||||
|
);
|
||||||
|
crate::downloader::clear_download_state_for_browser(browser);
|
||||||
|
let progress = crate::downloader::DownloadProgress {
|
||||||
|
browser: (*browser).to_string(),
|
||||||
|
version: version.clone(),
|
||||||
|
downloaded_bytes: 0,
|
||||||
|
total_bytes: None,
|
||||||
|
percentage: 0.0,
|
||||||
|
speed_bytes_per_sec: 0.0,
|
||||||
|
eta_seconds: None,
|
||||||
|
stage: "error".to_string(),
|
||||||
|
};
|
||||||
|
let _ = crate::events::emit("download-progress", &progress);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
log::warn!("Failed to auto-download {browser} {version}: {e}");
|
if attempt < MAX_ATTEMPTS {
|
||||||
|
// Short backoff before retrying a transient failure.
|
||||||
|
let backoff = std::time::Duration::from_secs(2u64.pow(attempt - 1));
|
||||||
|
tokio::time::sleep(backoff).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !succeeded {
|
||||||
|
// Do NOT abort the whole routine: continue so the next browser (Camoufox)
|
||||||
|
// still gets its chance even though this one failed/timed out.
|
||||||
|
log::warn!("Giving up on auto-download of {browser} {version} after {MAX_ATTEMPTS} attempts");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(downloaded)
|
Ok(downloaded)
|
||||||
|
|||||||
+125
-15
@@ -10,6 +10,11 @@ use crate::browser::{create_browser, BrowserType};
|
|||||||
use crate::browser_version_manager::DownloadInfo;
|
use crate::browser_version_manager::DownloadInfo;
|
||||||
use crate::events;
|
use crate::events;
|
||||||
|
|
||||||
|
// Maximum time to wait for the next chunk of a streaming download before treating
|
||||||
|
// the connection as stalled. Converts an indefinite hang into a terminal error so
|
||||||
|
// the UI can surface it and the caller can move on / retry.
|
||||||
|
const STREAM_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||||
|
|
||||||
// Global state to track currently downloading browser-version pairs
|
// Global state to track currently downloading browser-version pairs
|
||||||
lazy_static::lazy_static! {
|
lazy_static::lazy_static! {
|
||||||
static ref DOWNLOADING_BROWSERS: std::sync::Arc<Mutex<std::collections::HashSet<String>>> =
|
static ref DOWNLOADING_BROWSERS: std::sync::Arc<Mutex<std::collections::HashSet<String>>> =
|
||||||
@@ -44,6 +49,11 @@ impl Downloader {
|
|||||||
Self {
|
Self {
|
||||||
client: Client::builder()
|
client: Client::builder()
|
||||||
.connect_timeout(std::time::Duration::from_secs(30))
|
.connect_timeout(std::time::Duration::from_secs(30))
|
||||||
|
// Per-read idle timeout: if the connection stalls mid-stream with no bytes
|
||||||
|
// for this long, the read fails instead of hanging forever. This is the
|
||||||
|
// transport-level guard; the streaming loop also wraps each read in an
|
||||||
|
// explicit tokio timeout as defense-in-depth.
|
||||||
|
.read_timeout(STREAM_IDLE_TIMEOUT)
|
||||||
.build()
|
.build()
|
||||||
.unwrap_or_else(|_| Client::new()),
|
.unwrap_or_else(|_| Client::new()),
|
||||||
api_client: ApiClient::instance(),
|
api_client: ApiClient::instance(),
|
||||||
@@ -470,7 +480,26 @@ impl Downloader {
|
|||||||
let mut stream = response.bytes_stream();
|
let mut stream = response.bytes_stream();
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
while let Some(chunk) = stream.next().await {
|
loop {
|
||||||
|
// Wrap each read in an idle timeout so a stalled connection (no bytes flowing)
|
||||||
|
// surfaces as a terminal error instead of awaiting forever.
|
||||||
|
let next = match tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()).await {
|
||||||
|
Ok(item) => item,
|
||||||
|
Err(_) => {
|
||||||
|
drop(file);
|
||||||
|
// Keep any partial bytes on disk so a later attempt can resume via Range.
|
||||||
|
return Err(
|
||||||
|
format!(
|
||||||
|
"Download stalled: no data received for {}s",
|
||||||
|
STREAM_IDLE_TIMEOUT.as_secs()
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(chunk) = next else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
if let Some(token) = cancel_token {
|
if let Some(token) = cancel_token {
|
||||||
if token.is_cancelled() {
|
if token.is_cancelled() {
|
||||||
drop(file);
|
drop(file);
|
||||||
@@ -694,20 +723,25 @@ impl Downloader {
|
|||||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||||
tokens.remove(&download_key);
|
tokens.remove(&download_key);
|
||||||
|
|
||||||
// Emit cancelled stage if the download was cancelled by user
|
// Emit a terminal stage so the UI stops spinning. A user cancellation maps to
|
||||||
if cancel_token.is_cancelled() {
|
// "cancelled"; any other failure (network error, stall timeout, bad status)
|
||||||
let progress = DownloadProgress {
|
// maps to "error" so the frontend can show a concrete error toast.
|
||||||
browser: browser_str.clone(),
|
let stage = if cancel_token.is_cancelled() {
|
||||||
version: version.clone(),
|
"cancelled"
|
||||||
downloaded_bytes: 0,
|
} else {
|
||||||
total_bytes: None,
|
"error"
|
||||||
percentage: 0.0,
|
};
|
||||||
speed_bytes_per_sec: 0.0,
|
let progress = DownloadProgress {
|
||||||
eta_seconds: None,
|
browser: browser_str.clone(),
|
||||||
stage: "cancelled".to_string(),
|
version: version.clone(),
|
||||||
};
|
downloaded_bytes: 0,
|
||||||
let _ = events::emit("download-progress", &progress);
|
total_bytes: None,
|
||||||
}
|
percentage: 0.0,
|
||||||
|
speed_bytes_per_sec: 0.0,
|
||||||
|
eta_seconds: None,
|
||||||
|
stage: stage.to_string(),
|
||||||
|
};
|
||||||
|
let _ = events::emit("download-progress", &progress);
|
||||||
|
|
||||||
return Err(format!("Failed to download browser: {e}").into());
|
return Err(format!("Failed to download browser: {e}").into());
|
||||||
}
|
}
|
||||||
@@ -844,6 +878,20 @@ impl Downloader {
|
|||||||
// Do not delete files on verification failure; keep archive for manual retry.
|
// Do not delete files on verification failure; keep archive for manual retry.
|
||||||
let _ = self.registry.remove_browser(&browser_str, &version);
|
let _ = self.registry.remove_browser(&browser_str, &version);
|
||||||
let _ = self.registry.save();
|
let _ = self.registry.save();
|
||||||
|
|
||||||
|
// Emit a terminal error stage so the UI shows an error instead of spinning.
|
||||||
|
let progress = DownloadProgress {
|
||||||
|
browser: browser_str.clone(),
|
||||||
|
version: version.clone(),
|
||||||
|
downloaded_bytes: 0,
|
||||||
|
total_bytes: None,
|
||||||
|
percentage: 0.0,
|
||||||
|
speed_bytes_per_sec: 0.0,
|
||||||
|
eta_seconds: None,
|
||||||
|
stage: "error".to_string(),
|
||||||
|
};
|
||||||
|
let _ = events::emit("download-progress", &progress);
|
||||||
|
|
||||||
// Remove browser-version pair from downloading set on verification failure
|
// Remove browser-version pair from downloading set on verification failure
|
||||||
{
|
{
|
||||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||||
@@ -979,6 +1027,25 @@ pub fn is_downloading(browser: &str, version: &str) -> bool {
|
|||||||
downloading.contains(&download_key)
|
downloading.contains(&download_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear all in-progress download bookkeeping for a browser.
|
||||||
|
///
|
||||||
|
/// Used as a last-resort cleanup when a download future is abandoned (e.g. dropped
|
||||||
|
/// by an outer timeout) before its own error path could run. Because
|
||||||
|
/// `download_browser_full` may re-resolve to a different version than requested, this
|
||||||
|
/// matches by the `"{browser}-"` key prefix rather than an exact version so no stuck
|
||||||
|
/// key is left behind regardless of which version was actually in flight.
|
||||||
|
pub fn clear_download_state_for_browser(browser: &str) {
|
||||||
|
let prefix = format!("{browser}-");
|
||||||
|
{
|
||||||
|
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||||
|
downloading.retain(|key| !key.starts_with(&prefix));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||||
|
tokens.retain(|key, _| !key.starts_with(&prefix));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn download_browser(
|
pub async fn download_browser(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
@@ -1110,6 +1177,49 @@ mod tests {
|
|||||||
let downloaded_content = std::fs::read(&downloaded_file).unwrap();
|
let downloaded_content = std::fs::read(&downloaded_file).unwrap();
|
||||||
assert_eq!(downloaded_content.len(), test_content.len());
|
assert_eq!(downloaded_content.len(), test_content.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clear_download_state_for_browser_removes_stuck_keys() {
|
||||||
|
// Simulate a download future that was abandoned without running its own cleanup,
|
||||||
|
// leaving stuck bookkeeping for a version that differs from the requested one.
|
||||||
|
let key = "wayfern-1.2.3-resolved".to_string();
|
||||||
|
{
|
||||||
|
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||||
|
downloading.insert(key.clone());
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||||
|
tokens.insert(key.clone(), CancellationToken::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different browser's in-progress state must be left untouched.
|
||||||
|
let other = "camoufox-9.9.9".to_string();
|
||||||
|
{
|
||||||
|
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||||
|
downloading.insert(other.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
clear_download_state_for_browser("wayfern");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!is_downloading("wayfern", "1.2.3-resolved"),
|
||||||
|
"stuck wayfern key should be cleared even when version differs from request"
|
||||||
|
);
|
||||||
|
{
|
||||||
|
let tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||||
|
assert!(
|
||||||
|
!tokens.contains_key(&key),
|
||||||
|
"stuck wayfern cancellation token should be cleared"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
is_downloading("camoufox", "9.9.9"),
|
||||||
|
"unrelated browser's download state must be preserved"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup so we don't leak global state into other tests.
|
||||||
|
clear_download_state_for_browser("camoufox");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global singleton instance
|
// Global singleton instance
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ mod tests {
|
|||||||
dns_blocklist: None,
|
dns_blocklist: None,
|
||||||
password_protected: false,
|
password_protected: false,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
|
updated_at: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::broadcast;
|
|
||||||
|
|
||||||
/// Trait for emitting events to the frontend or connected clients.
|
/// Trait for emitting events to the frontend.
|
||||||
/// This abstraction allows the same code to work in both GUI (Tauri) mode
|
|
||||||
/// and daemon mode (WebSocket broadcast).
|
|
||||||
///
|
///
|
||||||
/// Note: This trait uses `serde_json::Value` to be dyn-compatible.
|
/// Note: This trait uses `serde_json::Value` to be dyn-compatible.
|
||||||
/// Use the convenience functions `emit()` and `emit_empty()` which accept
|
/// Use the convenience functions `emit()` and `emit_empty()` which accept
|
||||||
@@ -37,49 +34,6 @@ impl EventEmitter for TauriEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Event message sent through the daemon's broadcast channel.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct DaemonEvent {
|
|
||||||
pub event_type: String,
|
|
||||||
pub payload: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Daemon-based event emitter for background daemon mode.
|
|
||||||
/// Broadcasts events to all connected WebSocket clients.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct DaemonEmitter {
|
|
||||||
tx: broadcast::Sender<DaemonEvent>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DaemonEmitter {
|
|
||||||
pub fn new(tx: broadcast::Sender<DaemonEvent>) -> Self {
|
|
||||||
Self { tx }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new DaemonEmitter with a default channel capacity.
|
|
||||||
pub fn with_capacity(capacity: usize) -> (Self, broadcast::Receiver<DaemonEvent>) {
|
|
||||||
let (tx, rx) = broadcast::channel(capacity);
|
|
||||||
(Self { tx }, rx)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Subscribe to events from this emitter.
|
|
||||||
pub fn subscribe(&self) -> broadcast::Receiver<DaemonEvent> {
|
|
||||||
self.tx.subscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventEmitter for DaemonEmitter {
|
|
||||||
fn emit_value(&self, event: &str, payload: serde_json::Value) -> Result<(), String> {
|
|
||||||
let daemon_event = DaemonEvent {
|
|
||||||
event_type: event.to_string(),
|
|
||||||
payload,
|
|
||||||
};
|
|
||||||
// Ignore send errors (no receivers connected)
|
|
||||||
let _ = self.tx.send(daemon_event);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// No-op emitter for testing or when events are not needed.
|
/// No-op emitter for testing or when events are not needed.
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct NoopEmitter;
|
pub struct NoopEmitter;
|
||||||
@@ -91,8 +45,7 @@ impl EventEmitter for NoopEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Global event emitter that can be set at runtime.
|
/// Global event emitter that can be set at runtime.
|
||||||
/// This allows managers to emit events without knowing whether they're
|
/// This allows managers to emit events without holding an AppHandle directly.
|
||||||
/// running in GUI or daemon mode.
|
|
||||||
static GLOBAL_EMITTER: std::sync::OnceLock<Arc<dyn EventEmitter>> = std::sync::OnceLock::new();
|
static GLOBAL_EMITTER: std::sync::OnceLock<Arc<dyn EventEmitter>> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
/// Set the global event emitter. This should be called once during app startup.
|
/// Set the global event emitter. This should be called once during app startup.
|
||||||
@@ -136,30 +89,6 @@ mod tests {
|
|||||||
.is_ok());
|
.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_daemon_emitter() {
|
|
||||||
let (emitter, mut rx) = DaemonEmitter::with_capacity(16);
|
|
||||||
|
|
||||||
// Emit an event
|
|
||||||
let _ = emitter.emit_value("test-event", serde_json::json!("hello"));
|
|
||||||
|
|
||||||
// Check we received it
|
|
||||||
let event = rx.try_recv().unwrap();
|
|
||||||
assert_eq!(event.event_type, "test-event");
|
|
||||||
assert_eq!(event.payload, serde_json::json!("hello"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_daemon_emitter_no_receivers() {
|
|
||||||
let (tx, _) = broadcast::channel::<DaemonEvent>(16);
|
|
||||||
let emitter = DaemonEmitter::new(tx);
|
|
||||||
|
|
||||||
// Should not error even with no receivers
|
|
||||||
assert!(emitter
|
|
||||||
.emit_value("test-event", serde_json::json!("hello"))
|
|
||||||
.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_emit_convenience_function() {
|
fn test_emit_convenience_function() {
|
||||||
// Test that emit() works with various types
|
// Test that emit() works with various types
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ pub struct ProfileGroup {
|
|||||||
pub sync_enabled: bool,
|
pub sync_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_sync: Option<u64>,
|
pub last_sync: Option<u64>,
|
||||||
|
/// Unix seconds of the last meaningful user edit. Source of truth for sync
|
||||||
|
/// conflict resolution (last-write-wins); bumped on edits only.
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -90,6 +94,7 @@ impl GroupManager {
|
|||||||
name,
|
name,
|
||||||
sync_enabled,
|
sync_enabled,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: Some(crate::proxy_manager::now_secs()),
|
||||||
};
|
};
|
||||||
|
|
||||||
groups_data.groups.push(group.clone());
|
groups_data.groups.push(group.clone());
|
||||||
@@ -136,6 +141,7 @@ impl GroupManager {
|
|||||||
.ok_or_else(|| format!("Group with id '{id}' not found"))?;
|
.ok_or_else(|| format!("Group with id '{id}' not found"))?;
|
||||||
|
|
||||||
group.name = name;
|
group.name = name;
|
||||||
|
group.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
let updated_group = group.clone();
|
let updated_group = group.clone();
|
||||||
|
|
||||||
self.save_groups_data(&groups_data)?;
|
self.save_groups_data(&groups_data)?;
|
||||||
@@ -167,6 +173,7 @@ impl GroupManager {
|
|||||||
existing.name = group.name.clone();
|
existing.name = group.name.clone();
|
||||||
existing.sync_enabled = group.sync_enabled;
|
existing.sync_enabled = group.sync_enabled;
|
||||||
existing.last_sync = group.last_sync;
|
existing.last_sync = group.last_sync;
|
||||||
|
existing.updated_at = group.updated_at;
|
||||||
self.save_groups_data(&groups_data)?;
|
self.save_groups_data(&groups_data)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +190,7 @@ impl GroupManager {
|
|||||||
existing.name = group.name.clone();
|
existing.name = group.name.clone();
|
||||||
existing.sync_enabled = group.sync_enabled;
|
existing.sync_enabled = group.sync_enabled;
|
||||||
existing.last_sync = group.last_sync;
|
existing.last_sync = group.last_sync;
|
||||||
|
existing.updated_at = group.updated_at;
|
||||||
} else {
|
} else {
|
||||||
groups_data.groups.push(group.clone());
|
groups_data.groups.push(group.clone());
|
||||||
}
|
}
|
||||||
|
|||||||
+225
-26
@@ -1,13 +1,19 @@
|
|||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::{Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
|
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
|
||||||
use tauri_plugin_deep_link::DeepLinkExt;
|
use tauri_plugin_deep_link::DeepLinkExt;
|
||||||
use tauri_plugin_log::{Target, TargetKind};
|
use tauri_plugin_log::{Target, TargetKind};
|
||||||
|
|
||||||
// Store pending URLs that need to be handled when the window is ready
|
// Store pending URLs that need to be handled when the window is ready
|
||||||
static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
// Set to true once the user has confirmed they want to quit, so the close
|
||||||
|
// interceptor lets the next CloseRequested through instead of looping back
|
||||||
|
// to the confirmation dialog.
|
||||||
|
static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
mod api_client;
|
mod api_client;
|
||||||
mod api_server;
|
mod api_server;
|
||||||
mod app_auto_updater;
|
mod app_auto_updater;
|
||||||
@@ -46,11 +52,6 @@ mod wayfern_terms;
|
|||||||
pub mod cloud_auth;
|
pub mod cloud_auth;
|
||||||
mod commercial_license;
|
mod commercial_license;
|
||||||
mod cookie_manager;
|
mod cookie_manager;
|
||||||
pub mod daemon;
|
|
||||||
pub mod daemon_client;
|
|
||||||
#[allow(dead_code)]
|
|
||||||
mod daemon_spawn;
|
|
||||||
pub mod daemon_ws;
|
|
||||||
pub mod events;
|
pub mod events;
|
||||||
mod mcp_integrations;
|
mod mcp_integrations;
|
||||||
mod mcp_server;
|
mod mcp_server;
|
||||||
@@ -92,10 +93,10 @@ use downloaded_browsers_registry::{
|
|||||||
use downloader::{cancel_download, download_browser};
|
use downloader::{cancel_download, download_browser};
|
||||||
|
|
||||||
use settings_manager::{
|
use settings_manager::{
|
||||||
decline_launch_on_login, dismiss_window_resize_warning, enable_launch_on_login, get_app_settings,
|
complete_onboarding, dismiss_window_resize_warning, get_app_settings, get_onboarding_completed,
|
||||||
get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings,
|
get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings,
|
||||||
get_window_resize_warning_dismissed, open_log_directory, read_log_files, save_app_settings,
|
get_window_resize_warning_dismissed, open_log_directory, read_log_files, save_app_settings,
|
||||||
save_sync_settings, save_table_sorting_settings, should_show_launch_on_login_prompt,
|
save_sync_settings, save_table_sorting_settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
use sync::{
|
use sync::{
|
||||||
@@ -190,7 +191,8 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
// Called internally for deep-link / startup URL handling — not invoked from the
|
||||||
|
// frontend, so it is intentionally not a `#[tauri::command]`.
|
||||||
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
|
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
|
||||||
log::info!("handle_url_open called with URL: {url}");
|
log::info!("handle_url_open called with URL: {url}");
|
||||||
|
|
||||||
@@ -927,15 +929,21 @@ async fn update_vpn_config(vpn_id: String, name: String) -> Result<vpn::VpnConfi
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn check_vpn_validity(
|
async fn check_vpn_validity(
|
||||||
vpn_id: String,
|
vpn_id: String,
|
||||||
|
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
|
||||||
|
check_vpn_validity_core(&vpn_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check_vpn_validity_core(
|
||||||
|
vpn_id: &str,
|
||||||
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
|
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_secs();
|
.as_secs();
|
||||||
|
|
||||||
let had_existing_worker = vpn_worker_storage::find_vpn_worker_by_vpn_id(&vpn_id).is_some();
|
let had_existing_worker = vpn_worker_storage::find_vpn_worker_by_vpn_id(vpn_id).is_some();
|
||||||
|
|
||||||
let vpn_worker = vpn_worker_runner::start_vpn_worker(&vpn_id)
|
let vpn_worker = vpn_worker_runner::start_vpn_worker(vpn_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to start VPN worker: {e}"))?;
|
.map_err(|e| format!("Failed to start VPN worker: {e}"))?;
|
||||||
|
|
||||||
@@ -1012,6 +1020,53 @@ async fn check_vpn_validity(
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate that a profile's selected proxy or VPN actually works before the
|
||||||
|
/// profile is created. Shared by the Tauri command, REST API, and MCP create
|
||||||
|
/// paths so a dead/unreachable proxy or VPN (or a 402 from an expired proxy
|
||||||
|
/// subscription) fails creation identically everywhere. Returns structured
|
||||||
|
/// `{ "code": ... }` error strings the frontend translates via backend-errors.ts.
|
||||||
|
pub async fn validate_profile_network(
|
||||||
|
proxy_id: Option<&str>,
|
||||||
|
vpn_id: Option<&str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if let Some(vpn_id) = vpn_id.filter(|s| !s.is_empty()) {
|
||||||
|
let result = check_vpn_validity_core(vpn_id).await?;
|
||||||
|
if !result.is_valid {
|
||||||
|
return Err(serde_json::json!({ "code": "VPN_NOT_WORKING" }).to_string());
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(proxy_id) = proxy_id.filter(|s| !s.is_empty()) {
|
||||||
|
// The cloud-included proxy is managed infrastructure; its only failure mode
|
||||||
|
// is the user hitting their usage limit, which surfaces as a 402 at request
|
||||||
|
// time. There's nothing to pre-validate here.
|
||||||
|
if proxy_id == crate::proxy_manager::CLOUD_PROXY_ID {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let settings = crate::proxy_manager::PROXY_MANAGER
|
||||||
|
.get_proxy_settings_by_id(proxy_id)
|
||||||
|
.ok_or_else(|| format!("Proxy '{proxy_id}' not found"))?;
|
||||||
|
match crate::proxy_manager::PROXY_MANAGER
|
||||||
|
.check_proxy_validity(proxy_id, &settings)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) if result.is_valid => {}
|
||||||
|
Ok(_) => {
|
||||||
|
return Err(serde_json::json!({ "code": "PROXY_NOT_WORKING" }).to_string());
|
||||||
|
}
|
||||||
|
Err(err) if err.contains("402") => {
|
||||||
|
return Err(serde_json::json!({ "code": "PROXY_PAYMENT_REQUIRED" }).to_string());
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
return Err(serde_json::json!({ "code": "PROXY_NOT_WORKING" }).to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn connect_vpn(vpn_id: String) -> Result<(), String> {
|
async fn connect_vpn(vpn_id: String) -> Result<(), String> {
|
||||||
// Start VPN worker process (detached, survives GUI shutdown)
|
// Start VPN worker process (detached, survives GUI shutdown)
|
||||||
@@ -1120,6 +1175,7 @@ async fn generate_sample_fingerprint(
|
|||||||
dns_blocklist: None,
|
dns_blocklist: None,
|
||||||
password_protected: false,
|
password_protected: false,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
if browser == "camoufox" {
|
if browser == "camoufox" {
|
||||||
@@ -1145,6 +1201,120 @@ async fn generate_sample_fingerprint(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Confirm a quit chosen from the close-confirmation dialog and exit the app.
|
||||||
|
#[tauri::command]
|
||||||
|
fn confirm_quit(app_handle: tauri::AppHandle) {
|
||||||
|
QUIT_CONFIRMED.store(true, Ordering::SeqCst);
|
||||||
|
app_handle.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hide the main window so the app keeps running behind its tray icon.
|
||||||
|
#[tauri::command]
|
||||||
|
fn hide_to_tray(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
window.hide().map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_main_window(app_handle: &tauri::AppHandle) {
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the tray menu labels with localized strings pushed from the frontend
|
||||||
|
/// (which owns the active language). The item ids are unchanged so the existing
|
||||||
|
/// menu-event handler keeps matching.
|
||||||
|
#[tauri::command]
|
||||||
|
fn update_tray_menu(
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
show_label: String,
|
||||||
|
quit_label: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
||||||
|
if let Some(tray) = app_handle.tray_by_id("main") {
|
||||||
|
let show_item = MenuItemBuilder::with_id("tray_show", show_label)
|
||||||
|
.build(&app_handle)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let quit_item = MenuItemBuilder::with_id("tray_quit", quit_label)
|
||||||
|
.build(&app_handle)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let menu = MenuBuilder::new(&app_handle)
|
||||||
|
.item(&show_item)
|
||||||
|
.separator()
|
||||||
|
.item(&quit_item)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
tray.set_menu(Some(menu)).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the system tray. Best-effort: on Linux the tray depends on
|
||||||
|
/// libayatana-appindicator at runtime, so any failure here must not abort app
|
||||||
|
/// startup — the caller logs and continues without a tray.
|
||||||
|
fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
||||||
|
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||||
|
|
||||||
|
// Bootstrap labels only — the frontend pushes localized labels via
|
||||||
|
// `update_tray_menu` on mount and on language change, and the menu is only
|
||||||
|
// opened after a minimize-to-tray (post-mount), so these are never shown.
|
||||||
|
let show_item = MenuItemBuilder::with_id("tray_show", "Show Donut Browser").build(app)?;
|
||||||
|
let quit_item = MenuItemBuilder::with_id("tray_quit", "Quit").build(app)?;
|
||||||
|
let tray_menu = MenuBuilder::new(app)
|
||||||
|
.item(&show_item)
|
||||||
|
.separator()
|
||||||
|
.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.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-44.png");
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
let tray_icon_bytes: &[u8] = include_bytes!("../icons/tray-icon-win-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);
|
||||||
|
|
||||||
|
TrayIconBuilder::with_id("main")
|
||||||
|
.icon(tray_image)
|
||||||
|
.icon_as_template(cfg!(target_os = "macos"))
|
||||||
|
.tooltip("Donut Browser")
|
||||||
|
.menu(&tray_menu)
|
||||||
|
.show_menu_on_left_click(false)
|
||||||
|
.on_menu_event(|app_handle, event| match event.id().as_ref() {
|
||||||
|
"tray_show" => show_main_window(app_handle),
|
||||||
|
"tray_quit" => {
|
||||||
|
QUIT_CONFIRMED.store(true, Ordering::SeqCst);
|
||||||
|
app_handle.exit(0);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
// Click events are not delivered on Linux (AppIndicator/SNI only drives
|
||||||
|
// the menu), so left-click-to-restore is macOS/Windows only — Linux users
|
||||||
|
// restore via the "Show Donut Browser" menu item.
|
||||||
|
if let TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
button_state: MouseButtonState::Up,
|
||||||
|
..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
show_main_window(tray.app_handle());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let args: Vec<String> = env::args().collect();
|
let args: Vec<String> = env::args().collect();
|
||||||
@@ -1158,15 +1328,25 @@ pub fn run() {
|
|||||||
|
|
||||||
let log_file_name = app_dirs::app_name();
|
let log_file_name = app_dirs::app_name();
|
||||||
|
|
||||||
|
// Honor DONUTBROWSER_DATA_ROOT: when set, logs go to <root>/logs instead of
|
||||||
|
// the platform default app log dir, so all on-disk state lives under one root.
|
||||||
|
let file_log_target = match app_dirs::log_dir_override() {
|
||||||
|
Some(path) => Target::new(TargetKind::Folder {
|
||||||
|
path,
|
||||||
|
file_name: Some(log_file_name.to_string()),
|
||||||
|
}),
|
||||||
|
None => Target::new(TargetKind::LogDir {
|
||||||
|
file_name: Some(log_file_name.to_string()),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(
|
.plugin(
|
||||||
tauri_plugin_log::Builder::new()
|
tauri_plugin_log::Builder::new()
|
||||||
.clear_targets() // Clear default targets to avoid duplicates
|
.clear_targets() // Clear default targets to avoid duplicates
|
||||||
.target(Target::new(TargetKind::Stdout))
|
.target(Target::new(TargetKind::Stdout))
|
||||||
.target(Target::new(TargetKind::Webview))
|
.target(Target::new(TargetKind::Webview))
|
||||||
.target(Target::new(TargetKind::LogDir {
|
.target(file_log_target)
|
||||||
file_name: Some(log_file_name.to_string()),
|
|
||||||
}))
|
|
||||||
// 5 MB per rotated file × KeepAll — the previous 100 KB limit
|
// 5 MB per rotated file × KeepAll — the previous 100 KB limit
|
||||||
// truncated useful context in customer support reports; 50 MB
|
// truncated useful context in customer support reports; 50 MB
|
||||||
// turned out to be excessive disk pressure.
|
// turned out to be excessive disk pressure.
|
||||||
@@ -1218,14 +1398,6 @@ pub fn run() {
|
|||||||
mgr.ensure_icons_extracted();
|
mgr.ensure_icons_extracted();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Daemon (tray icon) is currently disabled — clean up any existing autostart
|
|
||||||
if daemon::autostart::is_autostart_enabled() {
|
|
||||||
log::info!("Removing daemon autostart (daemon is disabled)");
|
|
||||||
if let Err(e) = daemon::autostart::disable_autostart() {
|
|
||||||
log::warn!("Failed to remove daemon autostart: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the main window programmatically
|
// Create the main window programmatically
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
||||||
@@ -1243,6 +1415,32 @@ pub fn run() {
|
|||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
let window = win_builder.build().unwrap();
|
let window = win_builder.build().unwrap();
|
||||||
|
|
||||||
|
// System tray so the user can keep the app running after the close
|
||||||
|
// dialog's "Minimize" action hides the window. Best-effort: a tray
|
||||||
|
// failure (e.g. missing libayatana-appindicator on Linux) must never
|
||||||
|
// prevent the app from launching, so we log and continue without it.
|
||||||
|
if let Err(e) = setup_system_tray(app.handle()) {
|
||||||
|
log::warn!("System tray unavailable, continuing without it: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intercept the window close so the frontend can ask the user whether
|
||||||
|
// to minimize or quit. The app exits when `confirm_quit` flips
|
||||||
|
// QUIT_CONFIRMED — until then, every CloseRequested is held back.
|
||||||
|
{
|
||||||
|
let app_handle = app.handle().clone();
|
||||||
|
window.on_window_event(move |event| {
|
||||||
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
if QUIT_CONFIRMED.load(Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api.prevent_close();
|
||||||
|
if let Err(e) = app_handle.emit("close-confirm-requested", ()) {
|
||||||
|
log::warn!("Failed to emit close-confirm-requested: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Set transparent titlebar for macOS
|
// Set transparent titlebar for macOS
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
@@ -1954,6 +2152,9 @@ pub fn run() {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
confirm_quit,
|
||||||
|
hide_to_tray,
|
||||||
|
update_tray_menu,
|
||||||
get_supported_browsers,
|
get_supported_browsers,
|
||||||
is_browser_supported_on_platform,
|
is_browser_supported_on_platform,
|
||||||
download_browser,
|
download_browser,
|
||||||
@@ -1984,15 +2185,14 @@ pub fn run() {
|
|||||||
save_app_settings,
|
save_app_settings,
|
||||||
read_log_files,
|
read_log_files,
|
||||||
open_log_directory,
|
open_log_directory,
|
||||||
should_show_launch_on_login_prompt,
|
|
||||||
enable_launch_on_login,
|
|
||||||
decline_launch_on_login,
|
|
||||||
get_table_sorting_settings,
|
get_table_sorting_settings,
|
||||||
save_table_sorting_settings,
|
save_table_sorting_settings,
|
||||||
get_system_language,
|
get_system_language,
|
||||||
get_system_info,
|
get_system_info,
|
||||||
dismiss_window_resize_warning,
|
dismiss_window_resize_warning,
|
||||||
get_window_resize_warning_dismissed,
|
get_window_resize_warning_dismissed,
|
||||||
|
get_onboarding_completed,
|
||||||
|
complete_onboarding,
|
||||||
clear_all_version_cache_and_refetch,
|
clear_all_version_cache_and_refetch,
|
||||||
is_default_browser,
|
is_default_browser,
|
||||||
open_url_with_profile,
|
open_url_with_profile,
|
||||||
@@ -2104,7 +2304,6 @@ pub fn run() {
|
|||||||
disconnect_vpn,
|
disconnect_vpn,
|
||||||
get_vpn_status,
|
get_vpn_status,
|
||||||
list_active_vpn_connections,
|
list_active_vpn_connections,
|
||||||
handle_url_open,
|
|
||||||
// Cloud auth commands
|
// Cloud auth commands
|
||||||
cloud_auth::cloud_exchange_device_code,
|
cloud_auth::cloud_exchange_device_code,
|
||||||
cloud_auth::cloud_get_user,
|
cloud_auth::cloud_get_user,
|
||||||
|
|||||||
+153
-42
@@ -152,11 +152,11 @@ impl McpServer {
|
|||||||
self.is_running.load(Ordering::SeqCst)
|
self.is_running.load(Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn require_paid_subscription(feature: &str) -> Result<(), McpError> {
|
/// Gate an MCP tool on a capability the caller already resolved (e.g.
|
||||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
/// `CLOUD_AUTH.can_use_browser_automation().await`). Logs the rejected gate
|
||||||
// Log the failed gate so customer logs explain why an MCP tool returned
|
/// with enough state for support to diagnose, without leaking secrets.
|
||||||
// an error. Include enough state (logged-in vs not, plan, status) for
|
async fn require_capability(feature: &str, allowed: bool) -> Result<(), McpError> {
|
||||||
// support to diagnose without leaking secrets.
|
if !allowed {
|
||||||
let summary = match CLOUD_AUTH.get_user().await {
|
let summary = match CLOUD_AUTH.get_user().await {
|
||||||
Some(state) => format!(
|
Some(state) => format!(
|
||||||
"logged_in=true plan={} status={} period={:?}",
|
"logged_in=true plan={} status={} period={:?}",
|
||||||
@@ -164,10 +164,10 @@ impl McpServer {
|
|||||||
),
|
),
|
||||||
None => "logged_in=false".to_string(),
|
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 {
|
return Err(McpError {
|
||||||
code: -32000,
|
code: -32000,
|
||||||
message: format!("{feature} requires an active paid subscription"),
|
message: format!("{feature} requires a plan that includes this feature"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -286,6 +286,9 @@ impl McpServer {
|
|||||||
.delete(Self::handle_mcp_delete),
|
.delete(Self::handle_mcp_delete),
|
||||||
)
|
)
|
||||||
.route("/health", get(Self::handle_health))
|
.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(
|
.layer(middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
Self::auth_middleware,
|
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(
|
async fn auth_middleware(
|
||||||
State(state): State<McpHttpState>,
|
State(state): State<McpHttpState>,
|
||||||
req: Request<Body>,
|
req: Request<Body>,
|
||||||
@@ -339,8 +353,16 @@ impl McpServer {
|
|||||||
.and_then(|h| h.to_str().ok())
|
.and_then(|h| h.to_str().ok())
|
||||||
.and_then(|h| h.strip_prefix("Bearer "));
|
.and_then(|h| h.strip_prefix("Bearer "));
|
||||||
|
|
||||||
let valid =
|
// Constant-time comparison to avoid leaking the token prefix via timing.
|
||||||
path_token == Some(state.token.as_str()) || header_token == Some(state.token.as_str());
|
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 {
|
if !valid {
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
@@ -508,7 +530,7 @@ impl McpServer {
|
|||||||
},
|
},
|
||||||
McpTool {
|
McpTool {
|
||||||
name: "run_profile".to_string(),
|
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!({
|
input_schema: serde_json::json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -530,7 +552,7 @@ impl McpServer {
|
|||||||
},
|
},
|
||||||
McpTool {
|
McpTool {
|
||||||
name: "kill_profile".to_string(),
|
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!({
|
input_schema: serde_json::json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1639,10 +1661,21 @@ impl McpServer {
|
|||||||
"list_profiles" => self.handle_list_profiles().await,
|
"list_profiles" => self.handle_list_profiles().await,
|
||||||
"get_profile" => self.handle_get_profile(arguments).await,
|
"get_profile" => self.handle_get_profile(arguments).await,
|
||||||
"run_profile" => {
|
"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
|
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
|
||||||
|
}
|
||||||
"create_profile" => self.handle_create_profile(arguments).await,
|
"create_profile" => self.handle_create_profile(arguments).await,
|
||||||
"update_profile" => self.handle_update_profile(arguments).await,
|
"update_profile" => self.handle_update_profile(arguments).await,
|
||||||
"delete_profile" => self.handle_delete_profile(arguments).await,
|
"delete_profile" => self.handle_delete_profile(arguments).await,
|
||||||
@@ -1671,9 +1704,18 @@ impl McpServer {
|
|||||||
"connect_vpn" => self.handle_connect_vpn(arguments).await,
|
"connect_vpn" => self.handle_connect_vpn(arguments).await,
|
||||||
"disconnect_vpn" => self.handle_disconnect_vpn(arguments).await,
|
"disconnect_vpn" => self.handle_disconnect_vpn(arguments).await,
|
||||||
"get_vpn_status" => self.handle_get_vpn_status(arguments).await,
|
"get_vpn_status" => self.handle_get_vpn_status(arguments).await,
|
||||||
// Fingerprint management
|
// 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,
|
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(arguments).await,
|
||||||
"update_profile_fingerprint" => self.handle_update_profile_fingerprint(arguments).await,
|
"update_profile_fingerprint" => {
|
||||||
|
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" => {
|
"update_profile_proxy_bypass_rules" => {
|
||||||
self
|
self
|
||||||
.handle_update_profile_proxy_bypass_rules(arguments)
|
.handle_update_profile_proxy_bypass_rules(arguments)
|
||||||
@@ -1700,7 +1742,11 @@ impl McpServer {
|
|||||||
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
|
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
|
||||||
// Synchronizer tools
|
// Synchronizer tools
|
||||||
"start_sync_session" => {
|
"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
|
self.handle_start_sync_session(arguments).await
|
||||||
}
|
}
|
||||||
"stop_sync_session" => self.handle_stop_sync_session(arguments).await,
|
"stop_sync_session" => self.handle_stop_sync_session(arguments).await,
|
||||||
@@ -1708,43 +1754,83 @@ impl McpServer {
|
|||||||
"remove_sync_follower" => self.handle_remove_sync_follower(arguments).await,
|
"remove_sync_follower" => self.handle_remove_sync_follower(arguments).await,
|
||||||
// Browser interaction tools (require paid subscription)
|
// Browser interaction tools (require paid subscription)
|
||||||
"navigate" => {
|
"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
|
self.handle_navigate(arguments).await
|
||||||
}
|
}
|
||||||
"screenshot" => {
|
"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
|
self.handle_screenshot(arguments).await
|
||||||
}
|
}
|
||||||
"evaluate_javascript" => {
|
"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
|
self.handle_evaluate_javascript(arguments).await
|
||||||
}
|
}
|
||||||
"click_element" => {
|
"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
|
self.handle_click_element(arguments).await
|
||||||
}
|
}
|
||||||
"type_text" => {
|
"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
|
self.handle_type_text(arguments).await
|
||||||
}
|
}
|
||||||
"get_page_content" => {
|
"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
|
self.handle_get_page_content(arguments).await
|
||||||
}
|
}
|
||||||
"get_page_info" => {
|
"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
|
self.handle_get_page_info(arguments).await
|
||||||
}
|
}
|
||||||
"get_interactive_elements" => {
|
"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
|
self.handle_get_interactive_elements(arguments).await
|
||||||
}
|
}
|
||||||
"click_by_index" => {
|
"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
|
self.handle_click_by_index(arguments).await
|
||||||
}
|
}
|
||||||
"type_by_index" => {
|
"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
|
self.handle_type_by_index(arguments).await
|
||||||
}
|
}
|
||||||
_ => Err(McpError {
|
_ => Err(McpError {
|
||||||
@@ -1823,6 +1909,13 @@ impl McpServer {
|
|||||||
&self,
|
&self,
|
||||||
arguments: &serde_json::Value,
|
arguments: &serde_json::Value,
|
||||||
) -> Result<serde_json::Value, McpError> {
|
) -> 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
|
let profile_id = arguments
|
||||||
.get("profile_id")
|
.get("profile_id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -1832,7 +1925,7 @@ impl McpServer {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let url = arguments.get("url").and_then(|v| v.as_str());
|
let url = arguments.get("url").and_then(|v| v.as_str());
|
||||||
let _headless = arguments
|
let headless = arguments
|
||||||
.get("headless")
|
.get("headless")
|
||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
@@ -1876,19 +1969,21 @@ impl McpServer {
|
|||||||
message: "MCP server not properly initialized".to_string(),
|
message: "MCP server not properly initialized".to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Launch the browser
|
// Launch a fresh instance, honoring the requested headless mode. The CDP
|
||||||
crate::browser_runner::BrowserRunner::instance()
|
// port is self-allocated and discovered later via get_cdp_port_for_profile.
|
||||||
.launch_browser(
|
crate::browser_runner::launch_browser_profile_impl(
|
||||||
app_handle.clone(),
|
app_handle.clone(),
|
||||||
profile,
|
profile.clone(),
|
||||||
url.map(|s| s.to_string()),
|
url.map(|s| s.to_string()),
|
||||||
None,
|
None,
|
||||||
)
|
headless,
|
||||||
.await
|
true,
|
||||||
.map_err(|e| McpError {
|
)
|
||||||
code: -32000,
|
.await
|
||||||
message: format!("Failed to launch browser: {e}"),
|
.map_err(|e| McpError {
|
||||||
})?;
|
code: -32000,
|
||||||
|
message: format!("Failed to launch browser: {e}"),
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"content": [{
|
"content": [{
|
||||||
@@ -1902,6 +1997,13 @@ impl McpServer {
|
|||||||
&self,
|
&self,
|
||||||
arguments: &serde_json::Value,
|
arguments: &serde_json::Value,
|
||||||
) -> Result<serde_json::Value, McpError> {
|
) -> 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
|
let profile_id = arguments
|
||||||
.get("profile_id")
|
.get("profile_id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -2578,6 +2680,15 @@ impl McpServer {
|
|||||||
message: "Missing proxy_type".to_string(),
|
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
|
let host = arguments
|
||||||
.get("host")
|
.get("host")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -3229,10 +3340,10 @@ impl McpServer {
|
|||||||
&self,
|
&self,
|
||||||
arguments: &serde_json::Value,
|
arguments: &serde_json::Value,
|
||||||
) -> Result<serde_json::Value, McpError> {
|
) -> 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 {
|
return Err(McpError {
|
||||||
code: -32000,
|
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::path::Path;
|
||||||
use std::process::Command;
|
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
|
// Platform-specific modules
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -215,16 +251,7 @@ pub mod macos {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any command line argument contains the profile path
|
if cmd_matches_profile_path(cmd, 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 {
|
|
||||||
pids.push(pid.as_u32());
|
pids.push(pid.as_u32());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -832,15 +859,7 @@ pub mod linux {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let has_profile = cmd.iter().any(|arg| {
|
if cmd_matches_profile_path(cmd, profile_path) {
|
||||||
if let Some(arg_str) = arg.to_str() {
|
|
||||||
arg_str.contains(profile_path)
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if has_profile {
|
|
||||||
pids.push(pid.as_u32());
|
pids.push(pid.as_u32());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ impl ProfileManager {
|
|||||||
dns_blocklist: None,
|
dns_blocklist: None,
|
||||||
password_protected: false,
|
password_protected: false,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
match self
|
match self
|
||||||
@@ -303,6 +304,7 @@ impl ProfileManager {
|
|||||||
dns_blocklist: None,
|
dns_blocklist: None,
|
||||||
password_protected: false,
|
password_protected: false,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
match self
|
match self
|
||||||
@@ -365,6 +367,7 @@ impl ProfileManager {
|
|||||||
.map(|d| d.as_secs())
|
.map(|d| d.as_secs())
|
||||||
.unwrap_or(0),
|
.unwrap_or(0),
|
||||||
),
|
),
|
||||||
|
updated_at: Some(crate::proxy_manager::now_secs()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save profile info
|
// Save profile info
|
||||||
@@ -377,9 +380,18 @@ impl ProfileManager {
|
|||||||
|
|
||||||
log::info!("Profile '{name}' created successfully with ID: {profile_id}");
|
log::info!("Profile '{name}' created successfully with ID: {profile_id}");
|
||||||
|
|
||||||
// Create user.js with common Firefox preferences and apply proxy settings if provided
|
// `apply_proxy_settings_to_profile` writes a Firefox-style user.js
|
||||||
// Skip for ephemeral profiles since the data dir is created at launch time
|
// with the upstream proxy host. That is wrong for both supported
|
||||||
if !ephemeral {
|
// browser types:
|
||||||
|
// - Camoufox: camoufox_manager rewrites user.js at every launch with
|
||||||
|
// the local donut-proxy host; writing the upstream here leaves a
|
||||||
|
// stale, wrong proxy in user.js until the next launch.
|
||||||
|
// - Wayfern: Chromium gets its proxy via `--proxy-pac-url=` at launch
|
||||||
|
// (see wayfern_manager.rs) and never reads user.js.
|
||||||
|
// So we only call it for any unrecognized browser type that might be
|
||||||
|
// a true Firefox-family target (none currently). Ephemeral profiles
|
||||||
|
// skip regardless because their data dir is created at launch time.
|
||||||
|
if !ephemeral && !matches!(browser, "camoufox" | "wayfern") {
|
||||||
if let Some(proxy_id_ref) = &proxy_id {
|
if let Some(proxy_id_ref) = &proxy_id {
|
||||||
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
|
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
|
||||||
self.apply_proxy_settings_to_profile(&profile_data_dir, &proxy_settings, None)?;
|
self.apply_proxy_settings_to_profile(&profile_data_dir, &proxy_settings, None)?;
|
||||||
@@ -501,6 +513,7 @@ impl ProfileManager {
|
|||||||
|
|
||||||
// Update profile name (no need to move directories since we use UUID)
|
// Update profile name (no need to move directories since we use UUID)
|
||||||
profile.name = new_name.to_string();
|
profile.name = new_name.to_string();
|
||||||
|
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
|
|
||||||
// Save profile with new name
|
// Save profile with new name
|
||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
@@ -710,6 +723,7 @@ impl ProfileManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
profile.group_id = group_id.clone();
|
profile.group_id = group_id.clone();
|
||||||
|
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
|
|
||||||
crate::sync::queue_profile_sync_if_eligible(&profile);
|
crate::sync::queue_profile_sync_if_eligible(&profile);
|
||||||
@@ -764,6 +778,7 @@ impl ProfileManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
profile.tags = deduped;
|
profile.tags = deduped;
|
||||||
|
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
|
|
||||||
// Save profile
|
// Save profile
|
||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
@@ -800,6 +815,7 @@ impl ProfileManager {
|
|||||||
|
|
||||||
// Update note (trim whitespace, set to None if empty)
|
// Update note (trim whitespace, set to None if empty)
|
||||||
profile.note = note.map(|n| n.trim().to_string()).filter(|n| !n.is_empty());
|
profile.note = note.map(|n| n.trim().to_string()).filter(|n| !n.is_empty());
|
||||||
|
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
|
|
||||||
// Save profile
|
// Save profile
|
||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
@@ -829,6 +845,7 @@ impl ProfileManager {
|
|||||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||||
|
|
||||||
profile.launch_hook = Self::normalize_launch_hook(launch_hook)?;
|
profile.launch_hook = Self::normalize_launch_hook(launch_hook)?;
|
||||||
|
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
|
|
||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
|
|
||||||
@@ -860,6 +877,7 @@ impl ProfileManager {
|
|||||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||||
|
|
||||||
profile.proxy_bypass_rules = rules;
|
profile.proxy_bypass_rules = rules;
|
||||||
|
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
|
|
||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
|
|
||||||
@@ -886,6 +904,7 @@ impl ProfileManager {
|
|||||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||||
|
|
||||||
profile.dns_blocklist = dns_blocklist;
|
profile.dns_blocklist = dns_blocklist;
|
||||||
|
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
|
|
||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
|
|
||||||
@@ -1016,7 +1035,7 @@ impl ProfileManager {
|
|||||||
fs::create_dir_all(&dest_dir)?;
|
fs::create_dir_all(&dest_dir)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_profile = BrowserProfile {
|
let mut new_profile = BrowserProfile {
|
||||||
id: new_id,
|
id: new_id,
|
||||||
name: clone_name,
|
name: clone_name,
|
||||||
browser: source.browser,
|
browser: source.browser,
|
||||||
@@ -1049,8 +1068,24 @@ impl ProfileManager {
|
|||||||
.map(|d| d.as_secs())
|
.map(|d| d.as_secs())
|
||||||
.unwrap_or(0),
|
.unwrap_or(0),
|
||||||
),
|
),
|
||||||
|
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)?;
|
self.save_profile(&new_profile)?;
|
||||||
|
|
||||||
if let Err(e) = events::emit_empty("profiles-changed") {
|
if let Err(e) = events::emit_empty("profiles-changed") {
|
||||||
@@ -1216,6 +1251,7 @@ impl ProfileManager {
|
|||||||
// Update proxy settings and clear VPN (mutual exclusion)
|
// Update proxy settings and clear VPN (mutual exclusion)
|
||||||
profile.proxy_id = proxy_id.clone();
|
profile.proxy_id = proxy_id.clone();
|
||||||
profile.vpn_id = None;
|
profile.vpn_id = None;
|
||||||
|
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
|
|
||||||
// Save the updated profile
|
// Save the updated profile
|
||||||
self
|
self
|
||||||
@@ -1236,18 +1272,34 @@ impl ProfileManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update on-disk browser profile config immediately
|
// Update on-disk browser profile config immediately.
|
||||||
if let Some(proxy_id_ref) = &proxy_id {
|
// Both supported browser types ignore this write (Camoufox rewrites
|
||||||
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
|
// user.js at launch with the local donut-proxy host, Wayfern takes its
|
||||||
let profiles_dir = self.get_profiles_dir();
|
// proxy via `--proxy-pac-url=` and never reads user.js), and for
|
||||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
// Camoufox specifically writing the upstream host here would leave a
|
||||||
self
|
// stale, wrong proxy in user.js until the next launch.
|
||||||
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
|
if !matches!(profile.browser.as_str(), "camoufox" | "wayfern") {
|
||||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
if let Some(proxy_id_ref) = &proxy_id {
|
||||||
format!("Failed to apply proxy settings: {e}").into()
|
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
|
||||||
})?;
|
let profiles_dir = self.get_profiles_dir();
|
||||||
|
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||||
|
self
|
||||||
|
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
|
||||||
|
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||||
|
format!("Failed to apply proxy settings: {e}").into()
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
// Proxy ID provided but proxy not found, disable proxy
|
||||||
|
let profiles_dir = self.get_profiles_dir();
|
||||||
|
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||||
|
self
|
||||||
|
.disable_proxy_settings_in_profile(&profile_path)
|
||||||
|
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||||
|
format!("Failed to disable proxy settings: {e}").into()
|
||||||
|
})?;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Proxy ID provided but proxy not found, disable proxy
|
// No proxy ID provided, disable proxy
|
||||||
let profiles_dir = self.get_profiles_dir();
|
let profiles_dir = self.get_profiles_dir();
|
||||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||||
self
|
self
|
||||||
@@ -1256,15 +1308,6 @@ impl ProfileManager {
|
|||||||
format!("Failed to disable proxy settings: {e}").into()
|
format!("Failed to disable proxy settings: {e}").into()
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// No proxy ID provided, disable proxy
|
|
||||||
let profiles_dir = self.get_profiles_dir();
|
|
||||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
|
||||||
self
|
|
||||||
.disable_proxy_settings_in_profile(&profile_path)
|
|
||||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
|
||||||
format!("Failed to disable proxy settings: {e}").into()
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit profile update event so frontend UIs can refresh immediately (e.g. proxy manager)
|
// Emit profile update event so frontend UIs can refresh immediately (e.g. proxy manager)
|
||||||
@@ -1308,6 +1351,7 @@ impl ProfileManager {
|
|||||||
// Update VPN and clear proxy (mutual exclusion)
|
// Update VPN and clear proxy (mutual exclusion)
|
||||||
profile.vpn_id = vpn_id.clone();
|
profile.vpn_id = vpn_id.clone();
|
||||||
profile.proxy_id = None;
|
profile.proxy_id = None;
|
||||||
|
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
|
|
||||||
self
|
self
|
||||||
.save_profile(&profile)
|
.save_profile(&profile)
|
||||||
@@ -1352,6 +1396,7 @@ impl ProfileManager {
|
|||||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||||
|
|
||||||
profile.extension_group_id = extension_group_id.clone();
|
profile.extension_group_id = extension_group_id.clone();
|
||||||
|
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
|
|
||||||
crate::sync::queue_profile_sync_if_eligible(&profile);
|
crate::sync::queue_profile_sync_if_eligible(&profile);
|
||||||
@@ -2439,6 +2484,10 @@ pub async fn create_browser_profile_new(
|
|||||||
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
|
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A dead/unreachable proxy or VPN (or a 402 from an expired proxy
|
||||||
|
// subscription) cancels creation with a translatable error.
|
||||||
|
crate::validate_profile_network(proxy_id.as_deref(), vpn_id.as_deref()).await?;
|
||||||
|
|
||||||
let browser_type =
|
let browser_type =
|
||||||
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
|
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
|
||||||
create_browser_profile_with_group(
|
create_browser_profile_with_group(
|
||||||
@@ -2467,10 +2516,10 @@ pub async fn update_camoufox_config(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if config.fingerprint.is_some()
|
if config.fingerprint.is_some()
|
||||||
&& !crate::cloud_auth::CLOUD_AUTH
|
&& !crate::cloud_auth::CLOUD_AUTH
|
||||||
.has_active_paid_subscription()
|
.can_use_cross_os_fingerprints()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Err("Fingerprint editing requires an active Pro subscription".to_string());
|
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
if !crate::cloud_auth::CLOUD_AUTH
|
if !crate::cloud_auth::CLOUD_AUTH
|
||||||
@@ -2495,10 +2544,10 @@ pub async fn update_wayfern_config(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if config.fingerprint.is_some()
|
if config.fingerprint.is_some()
|
||||||
&& !crate::cloud_auth::CLOUD_AUTH
|
&& !crate::cloud_auth::CLOUD_AUTH
|
||||||
.has_active_paid_subscription()
|
.can_use_cross_os_fingerprints()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Err("Fingerprint editing requires an active Pro subscription".to_string());
|
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
if !crate::cloud_auth::CLOUD_AUTH
|
if !crate::cloud_auth::CLOUD_AUTH
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ pub struct BrowserProfile {
|
|||||||
/// any staleness check.
|
/// any staleness check.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub created_at: Option<u64>,
|
pub created_at: Option<u64>,
|
||||||
|
/// Unix seconds of the last meaningful metadata edit (name, tags, note,
|
||||||
|
/// proxy/vpn/group/extension assignment, launch hook, bypass rules, dns).
|
||||||
|
/// Source of truth for metadata sync conflict resolution (last-write-wins);
|
||||||
|
/// NOT bumped by browser-file changes, which sync via the file manifest.
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_release_type() -> String {
|
pub fn default_release_type() -> String {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use directories::BaseDirs;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fs::{self, create_dir_all};
|
use std::fs::{self, create_dir_all};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::camoufox_manager::CamoufoxConfig;
|
use crate::camoufox_manager::CamoufoxConfig;
|
||||||
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
||||||
@@ -21,11 +21,11 @@ pub struct DetectedProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn map_browser_type(browser: &str) -> &str {
|
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 {
|
match browser {
|
||||||
"firefox" | "firefox-developer" | "zen" => "camoufox",
|
"firefox" | "firefox-developer" | "zen" | "camoufox" => "camoufox",
|
||||||
"chromium" | "brave" => "wayfern",
|
|
||||||
"camoufox" => "camoufox",
|
|
||||||
"wayfern" => "wayfern",
|
|
||||||
_ => "wayfern",
|
_ => "wayfern",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,6 @@ pub struct ProfileImporter {
|
|||||||
base_dirs: BaseDirs,
|
base_dirs: BaseDirs,
|
||||||
downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
|
downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
|
||||||
profile_manager: &'static ProfileManager,
|
profile_manager: &'static ProfileManager,
|
||||||
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
|
|
||||||
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
|
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +43,6 @@ impl ProfileImporter {
|
|||||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||||
downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
|
downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
|
||||||
profile_manager: ProfileManager::instance(),
|
profile_manager: ProfileManager::instance(),
|
||||||
camoufox_manager: crate::camoufox_manager::CamoufoxManager::instance(),
|
|
||||||
wayfern_manager: crate::wayfern_manager::WayfernManager::instance(),
|
wayfern_manager: crate::wayfern_manager::WayfernManager::instance(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,12 +56,12 @@ impl ProfileImporter {
|
|||||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||||
let mut detected_profiles = Vec::new();
|
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_chrome_profiles()?);
|
||||||
detected_profiles.extend(self.detect_brave_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_chromium_profiles()?);
|
||||||
detected_profiles.extend(self.detect_zen_browser_profiles()?);
|
|
||||||
|
|
||||||
let mut seen_paths = HashSet::new();
|
let mut seen_paths = HashSet::new();
|
||||||
let unique_profiles: Vec<DetectedProfile> = detected_profiles
|
let unique_profiles: Vec<DetectedProfile> = detected_profiles
|
||||||
@@ -74,80 +72,6 @@ impl ProfileImporter {
|
|||||||
Ok(unique_profiles)
|
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>> {
|
fn detect_chrome_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||||
let mut profiles = Vec::new();
|
let mut profiles = Vec::new();
|
||||||
|
|
||||||
@@ -235,191 +159,6 @@ impl ProfileImporter {
|
|||||||
Ok(profiles)
|
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(
|
fn scan_chrome_profiles_dir(
|
||||||
&self,
|
&self,
|
||||||
browser_dir: &Path,
|
browser_dir: &Path,
|
||||||
@@ -493,7 +232,7 @@ impl ProfileImporter {
|
|||||||
browser_type: &str,
|
browser_type: &str,
|
||||||
new_profile_name: &str,
|
new_profile_name: &str,
|
||||||
proxy_id: Option<String>,
|
proxy_id: Option<String>,
|
||||||
camoufox_config: Option<CamoufoxConfig>,
|
_camoufox_config: Option<CamoufoxConfig>,
|
||||||
wayfern_config: Option<WayfernConfig>,
|
wayfern_config: Option<WayfernConfig>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let source_path = Path::new(source_path);
|
let source_path = Path::new(source_path);
|
||||||
@@ -529,87 +268,9 @@ impl ProfileImporter {
|
|||||||
|
|
||||||
let version = self.get_default_version_for_browser(mapped)?;
|
let version = self.get_default_version_for_browser(mapped)?;
|
||||||
|
|
||||||
let final_camoufox_config = if mapped == "camoufox" {
|
// Camoufox import is removed; only Wayfern profiles are imported now, so the
|
||||||
let mut config = camoufox_config.unwrap_or_default();
|
// imported profile never carries a Camoufox config.
|
||||||
|
let final_camoufox_config: Option<CamoufoxConfig> = None;
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
let final_wayfern_config = if mapped == "wayfern" {
|
let final_wayfern_config = if mapped == "wayfern" {
|
||||||
let mut config = wayfern_config.unwrap_or_default();
|
let mut config = wayfern_config.unwrap_or_default();
|
||||||
@@ -668,6 +329,7 @@ impl ProfileImporter {
|
|||||||
dns_blocklist: None,
|
dns_blocklist: None,
|
||||||
password_protected: false,
|
password_protected: false,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
match self
|
match self
|
||||||
@@ -726,6 +388,7 @@ impl ProfileImporter {
|
|||||||
.map(|d| d.as_secs())
|
.map(|d| d.as_secs())
|
||||||
.unwrap_or(0),
|
.unwrap_or(0),
|
||||||
),
|
),
|
||||||
|
updated_at: Some(crate::proxy_manager::now_secs()),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.profile_manager.save_profile(&profile)?;
|
self.profile_manager.save_profile(&profile)?;
|
||||||
@@ -803,6 +466,12 @@ pub async fn import_browser_profile(
|
|||||||
camoufox_config: Option<CamoufoxConfig>,
|
camoufox_config: Option<CamoufoxConfig>,
|
||||||
wayfern_config: Option<WayfernConfig>,
|
wayfern_config: Option<WayfernConfig>,
|
||||||
) -> Result<(), String> {
|
) -> 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
|
let fingerprint_os = camoufox_config
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|c| c.os.as_deref())
|
.and_then(|c| c.os.as_deref())
|
||||||
@@ -894,24 +563,6 @@ mod tests {
|
|||||||
let _profiles = result.unwrap();
|
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]
|
#[test]
|
||||||
fn test_scan_chrome_profiles_dir_nonexistent() {
|
fn test_scan_chrome_profiles_dir_nonexistent() {
|
||||||
let (importer, temp_dir) = create_test_profile_importer();
|
let (importer, temp_dir) = create_test_profile_importer();
|
||||||
@@ -930,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]
|
#[test]
|
||||||
fn test_copy_directory_recursive() {
|
fn test_copy_directory_recursive() {
|
||||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||||
|
|||||||
@@ -103,6 +103,11 @@ pub struct StoredProxy {
|
|||||||
pub sync_enabled: bool,
|
pub sync_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_sync: Option<u64>,
|
pub last_sync: Option<u64>,
|
||||||
|
/// Unix seconds of the last meaningful user edit. Source of truth for sync
|
||||||
|
/// conflict resolution (last-write-wins) — bumped on config edits only, never
|
||||||
|
/// by sync bookkeeping. `None` on legacy files is treated as 0.
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<u64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub is_cloud_managed: bool,
|
pub is_cloud_managed: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -124,6 +129,14 @@ pub struct StoredProxy {
|
|||||||
pub dynamic_proxy_format: Option<String>,
|
pub dynamic_proxy_format: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Current unix time in whole seconds. Used to stamp `updated_at` on edits.
|
||||||
|
pub fn now_secs() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
impl StoredProxy {
|
impl StoredProxy {
|
||||||
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
|
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
|
||||||
let sync_enabled = crate::sync::is_sync_configured();
|
let sync_enabled = crate::sync::is_sync_configured();
|
||||||
@@ -133,6 +146,7 @@ impl StoredProxy {
|
|||||||
proxy_settings,
|
proxy_settings,
|
||||||
sync_enabled,
|
sync_enabled,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: Some(now_secs()),
|
||||||
is_cloud_managed: false,
|
is_cloud_managed: false,
|
||||||
is_cloud_derived: false,
|
is_cloud_derived: false,
|
||||||
geo_country: None,
|
geo_country: None,
|
||||||
@@ -159,10 +173,12 @@ impl StoredProxy {
|
|||||||
|
|
||||||
pub fn update_settings(&mut self, proxy_settings: ProxySettings) {
|
pub fn update_settings(&mut self, proxy_settings: ProxySettings) {
|
||||||
self.proxy_settings = proxy_settings;
|
self.proxy_settings = proxy_settings;
|
||||||
|
self.updated_at = Some(now_secs());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_name(&mut self, name: String) {
|
pub fn update_name(&mut self, name: String) {
|
||||||
self.name = name;
|
self.name = name;
|
||||||
|
self.updated_at = Some(now_secs());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,6 +471,7 @@ impl ProxyManager {
|
|||||||
proxy_settings,
|
proxy_settings,
|
||||||
sync_enabled: false,
|
sync_enabled: false,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: Some(now_secs()),
|
||||||
is_cloud_managed: true,
|
is_cloud_managed: true,
|
||||||
is_cloud_derived: false,
|
is_cloud_derived: false,
|
||||||
geo_country: None,
|
geo_country: None,
|
||||||
@@ -646,6 +663,7 @@ impl ProxyManager {
|
|||||||
proxy_settings,
|
proxy_settings,
|
||||||
sync_enabled: false,
|
sync_enabled: false,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: Some(now_secs()),
|
||||||
is_cloud_managed: false,
|
is_cloud_managed: false,
|
||||||
is_cloud_derived: true,
|
is_cloud_derived: true,
|
||||||
geo_country: Some(country),
|
geo_country: Some(country),
|
||||||
@@ -710,6 +728,7 @@ impl ProxyManager {
|
|||||||
&proxy.geo_isp,
|
&proxy.geo_isp,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
proxy.updated_at = Some(now_secs());
|
||||||
proxy.proxy_settings.username = Some(geo_username);
|
proxy.proxy_settings.username = Some(geo_username);
|
||||||
proxy.proxy_settings.password = base_proxy.proxy_settings.password.clone();
|
proxy.proxy_settings.password = base_proxy.proxy_settings.password.clone();
|
||||||
proxy.proxy_settings.host = base_proxy.proxy_settings.host.clone();
|
proxy.proxy_settings.host = base_proxy.proxy_settings.host.clone();
|
||||||
@@ -755,6 +774,17 @@ impl ProxyManager {
|
|||||||
list
|
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
|
// Get a stored proxy by ID
|
||||||
|
|
||||||
// Update a stored proxy
|
// Update a stored proxy
|
||||||
@@ -1711,12 +1741,18 @@ impl ProxyManager {
|
|||||||
.arg("--id")
|
.arg("--id")
|
||||||
.arg(&proxy_id);
|
.arg(&proxy_id);
|
||||||
|
|
||||||
let output = proxy_cmd.output().await.unwrap();
|
// A failed spawn (sidecar missing, permission denied, fd exhaustion) must
|
||||||
|
// not panic the cleanup task — the proxy is already removed from tracking,
|
||||||
if !output.status.success() {
|
// so degrade gracefully like the non-success branch below.
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
match proxy_cmd.output().await {
|
||||||
log::warn!("Proxy stop error: {stderr}");
|
Ok(output) if !output.status.success() => {
|
||||||
// We still return Ok since we've already removed the proxy from our tracking
|
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
|
// Clear profile-to-proxy mapping if it references this proxy
|
||||||
@@ -1776,11 +1812,16 @@ impl ProxyManager {
|
|||||||
.arg("--id")
|
.arg("--id")
|
||||||
.arg(&proxy_id);
|
.arg(&proxy_id);
|
||||||
|
|
||||||
let output = proxy_cmd.output().await.unwrap();
|
// Don't panic if the sidecar can't be spawned — still clear the mapping.
|
||||||
|
match proxy_cmd.output().await {
|
||||||
if !output.status.success() {
|
Ok(output) if !output.status.success() => {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
log::warn!(
|
||||||
log::warn!("Proxy stop error: {stderr}");
|
"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
|
// Clear profile-to-proxy mapping
|
||||||
@@ -3154,6 +3195,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
sync_enabled: false,
|
sync_enabled: false,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: None,
|
||||||
is_cloud_managed: false,
|
is_cloud_managed: false,
|
||||||
is_cloud_derived: false,
|
is_cloud_derived: false,
|
||||||
geo_country: Some("US".to_string()),
|
geo_country: Some("US".to_string()),
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ fn unsuffixed_binary_name(base_name: &str) -> String {
|
|||||||
{
|
{
|
||||||
match base_name {
|
match base_name {
|
||||||
"donut-proxy" => "donut-proxy.exe".to_string(),
|
"donut-proxy" => "donut-proxy.exe".to_string(),
|
||||||
"donut-daemon" => "donut-daemon.exe".to_string(),
|
|
||||||
_ => String::new(),
|
_ => String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -509,47 +509,20 @@ async fn handle_http_via_socks4(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve target host to IP (SOCKS4 requires IP addresses)
|
// Build a SOCKS4a CONNECT request. We deliberately do NOT resolve the target
|
||||||
let target_ip = match tokio::net::lookup_host((target_host, target_port)).await {
|
// hostname locally: tokio::net::lookup_host would call the HOST resolver
|
||||||
Ok(mut addrs) => {
|
// (getaddrinfo), leaking the destination domain to the host's DNS server and
|
||||||
if let Some(addr) = addrs.next() {
|
// defeating the per-profile proxy. SOCKS4a has the PROXY resolve the name —
|
||||||
match addr.ip() {
|
// send the sentinel IP 0.0.0.x (x != 0), then the NULL-terminated userid, then
|
||||||
std::net::IpAddr::V4(ipv4) => ipv4.octets(),
|
// the NULL-terminated hostname. (Most SOCKS4 proxies support 4a; a legacy
|
||||||
std::net::IpAddr::V6(_) => {
|
// SOCKS4-only proxy without remote DNS cannot be used leak-free for plaintext
|
||||||
log::error!("SOCKS4 does not support IPv6");
|
// HTTP — prefer SOCKS5 there.)
|
||||||
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
|
|
||||||
let mut socks_request = vec![0x04, 0x01]; // SOCKS4, CONNECT
|
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_port.to_be_bytes());
|
||||||
socks_request.extend_from_slice(&target_ip);
|
socks_request.extend_from_slice(&[0, 0, 0, 1]); // 0.0.0.1 => SOCKS4a remote-DNS marker
|
||||||
socks_request.push(0); // NULL terminator for userid
|
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
|
// Send SOCKS4 CONNECT request
|
||||||
if let Err(e) = socks_stream.write_all(&socks_request).await {
|
if let Err(e) = socks_stream.write_all(&socks_request).await {
|
||||||
@@ -1071,8 +1044,19 @@ fn build_reqwest_client_with_proxy(
|
|||||||
Proxy::http(upstream_url)?
|
Proxy::http(upstream_url)?
|
||||||
}
|
}
|
||||||
"socks5" => {
|
"socks5" => {
|
||||||
// For SOCKS5, reqwest supports it directly
|
// Donut: force REMOTE (proxy-side) DNS for plaintext HTTP over a SOCKS5
|
||||||
Proxy::all(upstream_url)?
|
// 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" => {
|
||||||
// SOCKS4 is handled manually in handle_http_via_socks4
|
// SOCKS4 is handled manually in handle_http_via_socks4
|
||||||
@@ -1147,14 +1131,17 @@ pub async fn handle_proxy_connection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = handle_connect_from_buffer(
|
if let Err(e) = handle_connect_from_buffer(
|
||||||
stream,
|
stream,
|
||||||
full_request,
|
full_request,
|
||||||
upstream_url,
|
upstream_url,
|
||||||
bypass_matcher,
|
bypass_matcher,
|
||||||
blocklist_matcher,
|
blocklist_matcher,
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
|
log::warn!("CONNECT tunnel ended with error: {e}");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1449,6 +1436,13 @@ async fn handle_connect_from_buffer(
|
|||||||
tracker.record_request(&domain, 0, 0);
|
tracker.record_request(&domain, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"CONNECT {}:{} (upstream={})",
|
||||||
|
target_host,
|
||||||
|
target_port,
|
||||||
|
upstream_url.as_deref().unwrap_or("DIRECT")
|
||||||
|
);
|
||||||
|
|
||||||
// Connect to target (directly or via upstream proxy).
|
// Connect to target (directly or via upstream proxy).
|
||||||
// Returns a BoxedAsyncStream so all upstream types (plain TCP, SOCKS,
|
// Returns a BoxedAsyncStream so all upstream types (plain TCP, SOCKS,
|
||||||
// Shadowsocks) share the same bidirectional-copy tunnel code below.
|
// Shadowsocks) share the same bidirectional-copy tunnel code below.
|
||||||
@@ -1503,12 +1497,46 @@ async fn handle_connect_from_buffer(
|
|||||||
|
|
||||||
let mut buffer = [0u8; 4096];
|
let mut buffer = [0u8; 4096];
|
||||||
let n = proxy_stream.read(&mut buffer).await?;
|
let n = proxy_stream.read(&mut buffer).await?;
|
||||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
let response_full = String::from_utf8_lossy(&buffer[..n]).to_string();
|
||||||
|
let status_line = response_full.lines().next().unwrap_or("").to_string();
|
||||||
|
|
||||||
if !response.starts_with("HTTP/1.1 200") && !response.starts_with("HTTP/1.0 200") {
|
if !response_full.starts_with("HTTP/1.1 200")
|
||||||
return Err(format!("Upstream proxy CONNECT failed: {}", response).into());
|
&& !response_full.starts_with("HTTP/1.0 200")
|
||||||
|
{
|
||||||
|
log::warn!(
|
||||||
|
"Upstream CONNECT to {}:{} via {}:{} rejected: {}",
|
||||||
|
target_host,
|
||||||
|
target_port,
|
||||||
|
proxy_host,
|
||||||
|
proxy_port,
|
||||||
|
status_line
|
||||||
|
);
|
||||||
|
return Err(format!("Upstream proxy CONNECT failed: {response_full}").into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect the buffer-drop race where the upstream returned the
|
||||||
|
// 200 response coalesced with destination bytes — those bytes
|
||||||
|
// would otherwise be silently discarded and the browser would
|
||||||
|
// see a TLS stream missing its first record.
|
||||||
|
let header_end_in_buffer = response_full.find("\r\n\r\n").map(|i| i + 4);
|
||||||
|
if let Some(end) = header_end_in_buffer {
|
||||||
|
if end < n {
|
||||||
|
log::warn!(
|
||||||
|
"Upstream CONNECT response coalesced {} byte(s) of payload — these would be dropped without forwarding",
|
||||||
|
n - end
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Upstream CONNECT to {}:{} via {}:{} accepted ({})",
|
||||||
|
target_host,
|
||||||
|
target_port,
|
||||||
|
proxy_host,
|
||||||
|
proxy_port,
|
||||||
|
status_line
|
||||||
|
);
|
||||||
|
|
||||||
Box::new(proxy_stream)
|
Box::new(proxy_stream)
|
||||||
}
|
}
|
||||||
"socks4" | "socks5" => {
|
"socks4" | "socks5" => {
|
||||||
|
|||||||
@@ -50,12 +50,12 @@ pub struct AppSettings {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mcp_token: Option<String>, // Displayed token for user to copy (not persisted, loaded from encrypted file)
|
pub mcp_token: Option<String>, // Displayed token for user to copy (not persisted, loaded from encrypted file)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt
|
|
||||||
#[serde(default)]
|
|
||||||
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default
|
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub window_resize_warning_dismissed: bool,
|
pub window_resize_warning_dismissed: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub onboarding_completed: bool, // First-launch onboarding has been shown/handled (one-shot)
|
||||||
|
#[serde(default)]
|
||||||
pub disable_auto_updates: bool,
|
pub disable_auto_updates: bool,
|
||||||
/// When true, the decrypted in-RAM copy of a password-protected profile is
|
/// When true, the decrypted in-RAM copy of a password-protected profile is
|
||||||
/// preserved between launches for faster subsequent startups. The on-disk
|
/// preserved between launches for faster subsequent startups. The on-disk
|
||||||
@@ -93,9 +93,9 @@ impl Default for AppSettings {
|
|||||||
mcp_enabled: false,
|
mcp_enabled: false,
|
||||||
mcp_port: None,
|
mcp_port: None,
|
||||||
mcp_token: None,
|
mcp_token: None,
|
||||||
launch_on_login_declined: false,
|
|
||||||
language: None,
|
language: None,
|
||||||
window_resize_warning_dismissed: false,
|
window_resize_warning_dismissed: false,
|
||||||
|
onboarding_completed: false,
|
||||||
disable_auto_updates: false,
|
disable_auto_updates: false,
|
||||||
keep_decrypted_profiles_in_ram: false,
|
keep_decrypted_profiles_in_ram: false,
|
||||||
}
|
}
|
||||||
@@ -183,17 +183,6 @@ impl SettingsManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn should_show_launch_on_login_prompt(&self) -> Result<bool, Box<dyn std::error::Error>> {
|
|
||||||
// Daemon is currently disabled, never show this prompt
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decline_launch_on_login(&self) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let mut settings = self.load_settings()?;
|
|
||||||
settings.launch_on_login_declined = true;
|
|
||||||
self.save_settings(&settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_vault_password() -> String {
|
fn get_vault_password() -> String {
|
||||||
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
|
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
|
||||||
}
|
}
|
||||||
@@ -795,7 +784,6 @@ pub async fn save_app_settings(
|
|||||||
if let Ok(content) = std::fs::read_to_string(manager.get_settings_file()) {
|
if let Ok(content) = std::fs::read_to_string(manager.get_settings_file()) {
|
||||||
if let Ok(current) = serde_json::from_str::<AppSettings>(&content) {
|
if let Ok(current) = serde_json::from_str::<AppSettings>(&content) {
|
||||||
settings.window_resize_warning_dismissed = current.window_resize_warning_dismissed;
|
settings.window_resize_warning_dismissed = current.window_resize_warning_dismissed;
|
||||||
settings.launch_on_login_declined = current.launch_on_login_declined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -919,28 +907,6 @@ pub async fn open_log_directory(app_handle: tauri::AppHandle) -> Result<(), Stri
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn should_show_launch_on_login_prompt() -> Result<bool, String> {
|
|
||||||
let manager = SettingsManager::instance();
|
|
||||||
manager
|
|
||||||
.should_show_launch_on_login_prompt()
|
|
||||||
.map_err(|e| format!("Failed to check launch on login prompt setting: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn enable_launch_on_login() -> Result<(), String> {
|
|
||||||
crate::daemon::autostart::enable_autostart()
|
|
||||||
.map_err(|e| format!("Failed to enable autostart: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn decline_launch_on_login() -> Result<(), String> {
|
|
||||||
let manager = SettingsManager::instance();
|
|
||||||
manager
|
|
||||||
.decline_launch_on_login()
|
|
||||||
.map_err(|e| format!("Failed to decline launch on login: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String> {
|
pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String> {
|
||||||
let manager = SettingsManager::instance();
|
let manager = SettingsManager::instance();
|
||||||
@@ -1047,6 +1013,27 @@ pub async fn get_window_resize_warning_dismissed() -> Result<bool, String> {
|
|||||||
Ok(settings.window_resize_warning_dismissed)
|
Ok(settings.window_resize_warning_dismissed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_onboarding_completed() -> Result<bool, String> {
|
||||||
|
let manager = SettingsManager::instance();
|
||||||
|
let settings = manager
|
||||||
|
.load_settings()
|
||||||
|
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||||
|
Ok(settings.onboarding_completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn complete_onboarding() -> Result<(), String> {
|
||||||
|
let manager = SettingsManager::instance();
|
||||||
|
let mut settings = manager
|
||||||
|
.load_settings()
|
||||||
|
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||||
|
settings.onboarding_completed = true;
|
||||||
|
manager
|
||||||
|
.save_settings(&settings)
|
||||||
|
.map_err(|e| format!("Failed to save settings: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_system_language() -> String {
|
pub fn get_system_language() -> String {
|
||||||
sys_locale::get_locale()
|
sys_locale::get_locale()
|
||||||
@@ -1182,9 +1169,9 @@ mod tests {
|
|||||||
mcp_enabled: false,
|
mcp_enabled: false,
|
||||||
mcp_port: None,
|
mcp_port: None,
|
||||||
mcp_token: None,
|
mcp_token: None,
|
||||||
launch_on_login_declined: false,
|
|
||||||
language: None,
|
language: None,
|
||||||
window_resize_warning_dismissed: false,
|
window_resize_warning_dismissed: false,
|
||||||
|
onboarding_completed: false,
|
||||||
disable_auto_updates: false,
|
disable_auto_updates: false,
|
||||||
keep_decrypted_profiles_in_ram: false,
|
keep_decrypted_profiles_in_ram: false,
|
||||||
};
|
};
|
||||||
@@ -1247,29 +1234,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_should_show_launch_on_login_prompt() {
|
|
||||||
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
|
||||||
|
|
||||||
let result = manager.should_show_launch_on_login_prompt();
|
|
||||||
assert!(result.is_ok(), "Should not fail");
|
|
||||||
|
|
||||||
let _should_show = result.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_decline_launch_on_login() {
|
|
||||||
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
|
||||||
|
|
||||||
let settings = manager.load_settings().unwrap();
|
|
||||||
assert!(!settings.launch_on_login_declined);
|
|
||||||
|
|
||||||
manager.decline_launch_on_login().unwrap();
|
|
||||||
|
|
||||||
let settings = manager.load_settings().unwrap();
|
|
||||||
assert!(settings.launch_on_login_declined);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_load_corrupted_settings_file() {
|
fn test_load_corrupted_settings_file() {
|
||||||
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||||
|
|||||||
@@ -49,6 +49,21 @@ impl SyncClient {
|
|||||||
&self,
|
&self,
|
||||||
key: &str,
|
key: &str,
|
||||||
content_type: Option<&str>,
|
content_type: Option<&str>,
|
||||||
|
) -> SyncResult<PresignUploadResponse> {
|
||||||
|
self
|
||||||
|
.presign_upload_with_metadata(key, content_type, None)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Presign an upload, asking the server to sign `metadata` into the object as
|
||||||
|
/// `x-amz-meta-*`. The response echoes the metadata the server actually signed
|
||||||
|
/// (empty/None on older servers); the caller must send exactly that back on
|
||||||
|
/// the PUT via `upload_bytes_with_metadata`.
|
||||||
|
pub async fn presign_upload_with_metadata(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
content_type: Option<&str>,
|
||||||
|
metadata: Option<std::collections::HashMap<String, String>>,
|
||||||
) -> SyncResult<PresignUploadResponse> {
|
) -> SyncResult<PresignUploadResponse> {
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
@@ -58,6 +73,7 @@ impl SyncClient {
|
|||||||
key: key.to_string(),
|
key: key.to_string(),
|
||||||
content_type: content_type.map(|s| s.to_string()),
|
content_type: content_type.map(|s| s.to_string()),
|
||||||
expires_in: Some(3600),
|
expires_in: Some(3600),
|
||||||
|
metadata,
|
||||||
})
|
})
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -186,6 +202,21 @@ impl SyncClient {
|
|||||||
presigned_url: &str,
|
presigned_url: &str,
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
content_type: Option<&str>,
|
content_type: Option<&str>,
|
||||||
|
) -> SyncResult<()> {
|
||||||
|
self
|
||||||
|
.upload_bytes_with_metadata(presigned_url, data, content_type, None)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT to a presigned URL, sending `metadata` as `x-amz-meta-*` headers. These
|
||||||
|
/// MUST be exactly the metadata the presign signed (from
|
||||||
|
/// `PresignUploadResponse::metadata`) or S3 rejects the request.
|
||||||
|
pub async fn upload_bytes_with_metadata(
|
||||||
|
&self,
|
||||||
|
presigned_url: &str,
|
||||||
|
data: &[u8],
|
||||||
|
content_type: Option<&str>,
|
||||||
|
metadata: Option<&std::collections::HashMap<String, String>>,
|
||||||
) -> SyncResult<()> {
|
) -> SyncResult<()> {
|
||||||
let mut req = self
|
let mut req = self
|
||||||
.client
|
.client
|
||||||
@@ -197,6 +228,12 @@ impl SyncClient {
|
|||||||
req = req.header("Content-Type", ct);
|
req = req.header("Content-Type", ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(meta) = metadata {
|
||||||
|
for (k, v) in meta {
|
||||||
|
req = req.header(format!("x-amz-meta-{k}"), v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let response = req
|
let response = req
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
|
|||||||
+107
-102
@@ -15,6 +15,11 @@ use std::sync::{Arc, Mutex as StdMutex};
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tokio::sync::{Mutex as TokioMutex, Semaphore};
|
use tokio::sync::{Mutex as TokioMutex, Semaphore};
|
||||||
|
|
||||||
|
/// S3 object-metadata key (stored as `x-amz-meta-updated-at`) holding an
|
||||||
|
/// entity's user-edit timestamp in unix seconds. Used to resolve sync conflicts
|
||||||
|
/// (last-write-wins) from a HEAD request without downloading the object body.
|
||||||
|
const UPDATED_AT_META_KEY: &str = "updated-at";
|
||||||
|
|
||||||
lazy_static::lazy_static! {
|
lazy_static::lazy_static! {
|
||||||
static ref SYNC_CANCEL_FLAGS: StdMutex<HashMap<String, Arc<AtomicBool>>> =
|
static ref SYNC_CANCEL_FLAGS: StdMutex<HashMap<String, Arc<AtomicBool>>> =
|
||||||
StdMutex::new(HashMap::new());
|
StdMutex::new(HashMap::new());
|
||||||
@@ -289,7 +294,10 @@ impl SyncProgressTracker {
|
|||||||
|
|
||||||
/// Check if sync is configured (cloud or self-hosted)
|
/// Check if sync is configured (cloud or self-hosted)
|
||||||
pub fn is_sync_configured() -> bool {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
let manager = SettingsManager::instance();
|
let manager = SettingsManager::instance();
|
||||||
@@ -358,6 +366,67 @@ impl SyncEngine {
|
|||||||
!crate::cloud_auth::CLOUD_AUTH.is_logged_in().await
|
!crate::cloud_auth::CLOUD_AUTH.is_logged_in().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a remote config object's user-edit timestamp (`updated_at`) for
|
||||||
|
/// conflict resolution. Prefers the value from S3 object metadata returned by
|
||||||
|
/// the HEAD (`stat`) — no body transfer. Falls back to downloading and
|
||||||
|
/// decrypting the small JSON body and reading its embedded `updated_at` (for
|
||||||
|
/// older self-hosted servers that don't surface metadata). Legacy objects with
|
||||||
|
/// neither resolve to 0, so any real local edit (`updated_at` > 0) wins.
|
||||||
|
async fn remote_updated_at(&self, stat: &StatResponse, remote_key: &str) -> u64 {
|
||||||
|
if let Some(meta) = &stat.metadata {
|
||||||
|
if let Some(v) = meta
|
||||||
|
.get(UPDATED_AT_META_KEY)
|
||||||
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
|
{
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: read updated_at from the (small) JSON body.
|
||||||
|
if let Ok(presign) = self.client.presign_download(remote_key).await {
|
||||||
|
if let Ok(raw) = self.client.download_bytes(&presign.url).await {
|
||||||
|
if let Ok(data) = encryption::maybe_unseal_after_download(&raw) {
|
||||||
|
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&data) {
|
||||||
|
if let Some(u) = val.get("updated_at").and_then(|x| x.as_u64()) {
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload a small config JSON blob (proxy/vpn/group/extension/extension-group/
|
||||||
|
/// profile metadata), signing its `updated_at` into S3 object metadata so
|
||||||
|
/// future reconciles can compare via HEAD without downloading the body. The
|
||||||
|
/// body is sealed (E2E) exactly as before; only a plaintext unix timestamp
|
||||||
|
/// lives in the object metadata.
|
||||||
|
async fn upload_config_json(
|
||||||
|
&self,
|
||||||
|
remote_key: &str,
|
||||||
|
json: &str,
|
||||||
|
updated_at: u64,
|
||||||
|
) -> SyncResult<()> {
|
||||||
|
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
||||||
|
.map_err(|e| SyncError::InvalidData(format!("Failed to seal config: {e}")))?;
|
||||||
|
let mut meta = HashMap::new();
|
||||||
|
meta.insert(UPDATED_AT_META_KEY.to_string(), updated_at.to_string());
|
||||||
|
let presign = self
|
||||||
|
.client
|
||||||
|
.presign_upload_with_metadata(remote_key, Some(content_type), Some(meta))
|
||||||
|
.await?;
|
||||||
|
self
|
||||||
|
.client
|
||||||
|
.upload_bytes_with_metadata(
|
||||||
|
&presign.url,
|
||||||
|
&payload,
|
||||||
|
Some(content_type),
|
||||||
|
presign.metadata.as_ref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn sync_profile(
|
pub async fn sync_profile(
|
||||||
&self,
|
&self,
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle,
|
||||||
@@ -1431,21 +1500,13 @@ impl SyncEngine {
|
|||||||
|
|
||||||
match (local_proxy, stat.exists) {
|
match (local_proxy, stat.exists) {
|
||||||
(Some(proxy), true) => {
|
(Some(proxy), true) => {
|
||||||
// Both exist - compare timestamps
|
// Both exist - resolve by user-edit timestamp (last-write-wins).
|
||||||
let local_updated = proxy.last_sync.unwrap_or(0);
|
let local_updated = proxy.updated_at.unwrap_or(0);
|
||||||
let remote_updated: DateTime<Utc> = stat
|
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
|
||||||
.last_modified
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
|
||||||
.map(|dt| dt.with_timezone(&Utc))
|
|
||||||
.unwrap_or_else(Utc::now);
|
|
||||||
let remote_ts = remote_updated.timestamp() as u64;
|
|
||||||
|
|
||||||
if remote_ts > local_updated {
|
if remote_updated > local_updated {
|
||||||
// Remote is newer - download
|
|
||||||
self.download_proxy(proxy_id, app_handle).await?;
|
self.download_proxy(proxy_id, app_handle).await?;
|
||||||
} else if local_updated > remote_ts {
|
} else if local_updated > remote_updated {
|
||||||
// Local is newer - upload
|
|
||||||
self.upload_proxy(&proxy).await?;
|
self.upload_proxy(&proxy).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1478,17 +1539,9 @@ impl SyncEngine {
|
|||||||
let json = serde_json::to_string_pretty(&updated_proxy)
|
let json = serde_json::to_string_pretty(&updated_proxy)
|
||||||
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize proxy: {e}")))?;
|
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize proxy: {e}")))?;
|
||||||
|
|
||||||
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
|
||||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal proxy: {e}")))?;
|
|
||||||
|
|
||||||
let remote_key = format!("proxies/{}.json", proxy.id);
|
let remote_key = format!("proxies/{}.json", proxy.id);
|
||||||
let presign = self
|
|
||||||
.client
|
|
||||||
.presign_upload(&remote_key, Some(content_type))
|
|
||||||
.await?;
|
|
||||||
self
|
self
|
||||||
.client
|
.upload_config_json(&remote_key, &json, updated_proxy.updated_at.unwrap_or(0))
|
||||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Update local proxy with new last_sync (always write plaintext locally)
|
// Update local proxy with new last_sync (always write plaintext locally)
|
||||||
@@ -1547,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
|
// Emit event for UI update
|
||||||
if let Some(_handle) = app_handle {
|
if let Some(_handle) = app_handle {
|
||||||
let _ = events::emit("stored-proxies-changed", ());
|
let _ = events::emit("stored-proxies-changed", ());
|
||||||
@@ -1579,21 +1639,13 @@ impl SyncEngine {
|
|||||||
|
|
||||||
match (local_group, stat.exists) {
|
match (local_group, stat.exists) {
|
||||||
(Some(group), true) => {
|
(Some(group), true) => {
|
||||||
// Both exist - compare timestamps
|
// Both exist - resolve by user-edit timestamp (last-write-wins).
|
||||||
let local_updated = group.last_sync.unwrap_or(0);
|
let local_updated = group.updated_at.unwrap_or(0);
|
||||||
let remote_updated: DateTime<Utc> = stat
|
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
|
||||||
.last_modified
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
|
||||||
.map(|dt| dt.with_timezone(&Utc))
|
|
||||||
.unwrap_or_else(Utc::now);
|
|
||||||
let remote_ts = remote_updated.timestamp() as u64;
|
|
||||||
|
|
||||||
if remote_ts > local_updated {
|
if remote_updated > local_updated {
|
||||||
// Remote is newer - download
|
|
||||||
self.download_group(group_id, app_handle).await?;
|
self.download_group(group_id, app_handle).await?;
|
||||||
} else if local_updated > remote_ts {
|
} else if local_updated > remote_updated {
|
||||||
// Local is newer - upload
|
|
||||||
self.upload_group(&group).await?;
|
self.upload_group(&group).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1626,17 +1678,9 @@ impl SyncEngine {
|
|||||||
let json = serde_json::to_string_pretty(&updated_group)
|
let json = serde_json::to_string_pretty(&updated_group)
|
||||||
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize group: {e}")))?;
|
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize group: {e}")))?;
|
||||||
|
|
||||||
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
|
||||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal group: {e}")))?;
|
|
||||||
|
|
||||||
let remote_key = format!("groups/{}.json", group.id);
|
let remote_key = format!("groups/{}.json", group.id);
|
||||||
let presign = self
|
|
||||||
.client
|
|
||||||
.presign_upload(&remote_key, Some(content_type))
|
|
||||||
.await?;
|
|
||||||
self
|
self
|
||||||
.client
|
.upload_config_json(&remote_key, &json, updated_group.updated_at.unwrap_or(0))
|
||||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Update local group with new last_sync
|
// Update local group with new last_sync
|
||||||
@@ -1795,18 +1839,13 @@ impl SyncEngine {
|
|||||||
|
|
||||||
match (local_vpn, stat.exists) {
|
match (local_vpn, stat.exists) {
|
||||||
(Some(vpn), true) => {
|
(Some(vpn), true) => {
|
||||||
let local_updated = vpn.last_sync.unwrap_or(0);
|
// Both exist - resolve by user-edit timestamp (last-write-wins).
|
||||||
let remote_updated: DateTime<Utc> = stat
|
let local_updated = vpn.updated_at.unwrap_or(0);
|
||||||
.last_modified
|
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
|
||||||
.as_ref()
|
|
||||||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
|
||||||
.map(|dt| dt.with_timezone(&Utc))
|
|
||||||
.unwrap_or_else(Utc::now);
|
|
||||||
let remote_ts = remote_updated.timestamp() as u64;
|
|
||||||
|
|
||||||
if remote_ts > local_updated {
|
if remote_updated > local_updated {
|
||||||
self.download_vpn(vpn_id, app_handle).await?;
|
self.download_vpn(vpn_id, app_handle).await?;
|
||||||
} else if local_updated > remote_ts {
|
} else if local_updated > remote_updated {
|
||||||
self.upload_vpn(&vpn).await?;
|
self.upload_vpn(&vpn).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1836,17 +1875,9 @@ impl SyncEngine {
|
|||||||
let json = serde_json::to_string_pretty(&updated_vpn)
|
let json = serde_json::to_string_pretty(&updated_vpn)
|
||||||
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize VPN: {e}")))?;
|
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize VPN: {e}")))?;
|
||||||
|
|
||||||
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
|
||||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal VPN: {e}")))?;
|
|
||||||
|
|
||||||
let remote_key = format!("vpns/{}.json", vpn.id);
|
let remote_key = format!("vpns/{}.json", vpn.id);
|
||||||
let presign = self
|
|
||||||
.client
|
|
||||||
.presign_upload(&remote_key, Some(content_type))
|
|
||||||
.await?;
|
|
||||||
self
|
self
|
||||||
.client
|
.upload_config_json(&remote_key, &json, updated_vpn.updated_at.unwrap_or(0))
|
||||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Update local VPN with new last_sync
|
// Update local VPN with new last_sync
|
||||||
@@ -1946,18 +1977,13 @@ impl SyncEngine {
|
|||||||
|
|
||||||
match (local_ext, stat.exists) {
|
match (local_ext, stat.exists) {
|
||||||
(Some(ext), true) => {
|
(Some(ext), true) => {
|
||||||
let local_updated = ext.last_sync.unwrap_or(0);
|
// Both exist - resolve by user-edit timestamp (last-write-wins).
|
||||||
let remote_updated: DateTime<Utc> = stat
|
let local_updated = ext.updated_at;
|
||||||
.last_modified
|
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
|
||||||
.as_ref()
|
|
||||||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
|
||||||
.map(|dt| dt.with_timezone(&Utc))
|
|
||||||
.unwrap_or_else(Utc::now);
|
|
||||||
let remote_ts = remote_updated.timestamp() as u64;
|
|
||||||
|
|
||||||
if remote_ts > local_updated {
|
if remote_updated > local_updated {
|
||||||
self.download_extension(ext_id, app_handle).await?;
|
self.download_extension(ext_id, app_handle).await?;
|
||||||
} else if local_updated > remote_ts {
|
} else if local_updated > remote_updated {
|
||||||
self.upload_extension(&ext).await?;
|
self.upload_extension(&ext).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1987,17 +2013,9 @@ impl SyncEngine {
|
|||||||
let json = serde_json::to_string_pretty(&updated_ext)
|
let json = serde_json::to_string_pretty(&updated_ext)
|
||||||
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize extension: {e}")))?;
|
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize extension: {e}")))?;
|
||||||
|
|
||||||
let (meta_payload, meta_content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
|
||||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension: {e}")))?;
|
|
||||||
|
|
||||||
let remote_key = format!("extensions/{}.json", ext.id);
|
let remote_key = format!("extensions/{}.json", ext.id);
|
||||||
let presign = self
|
|
||||||
.client
|
|
||||||
.presign_upload(&remote_key, Some(meta_content_type))
|
|
||||||
.await?;
|
|
||||||
self
|
self
|
||||||
.client
|
.upload_config_json(&remote_key, &json, updated_ext.updated_at)
|
||||||
.upload_bytes(&presign.url, &meta_payload, Some(meta_content_type))
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Also upload the extension file data — encrypted as a sealed envelope
|
// Also upload the extension file data — encrypted as a sealed envelope
|
||||||
@@ -2151,18 +2169,13 @@ impl SyncEngine {
|
|||||||
|
|
||||||
match (local_group, stat.exists) {
|
match (local_group, stat.exists) {
|
||||||
(Some(group), true) => {
|
(Some(group), true) => {
|
||||||
let local_updated = group.last_sync.unwrap_or(0);
|
// Both exist - resolve by user-edit timestamp (last-write-wins).
|
||||||
let remote_updated: DateTime<Utc> = stat
|
let local_updated = group.updated_at;
|
||||||
.last_modified
|
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
|
||||||
.as_ref()
|
|
||||||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
|
||||||
.map(|dt| dt.with_timezone(&Utc))
|
|
||||||
.unwrap_or_else(Utc::now);
|
|
||||||
let remote_ts = remote_updated.timestamp() as u64;
|
|
||||||
|
|
||||||
if remote_ts > local_updated {
|
if remote_updated > local_updated {
|
||||||
self.download_extension_group(group_id, app_handle).await?;
|
self.download_extension_group(group_id, app_handle).await?;
|
||||||
} else if local_updated > remote_ts {
|
} else if local_updated > remote_updated {
|
||||||
self.upload_extension_group(&group).await?;
|
self.upload_extension_group(&group).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2196,17 +2209,9 @@ impl SyncEngine {
|
|||||||
SyncError::SerializationError(format!("Failed to serialize extension group: {e}"))
|
SyncError::SerializationError(format!("Failed to serialize extension group: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
|
||||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension group: {e}")))?;
|
|
||||||
|
|
||||||
let remote_key = format!("extension_groups/{}.json", group.id);
|
let remote_key = format!("extension_groups/{}.json", group.id);
|
||||||
let presign = self
|
|
||||||
.client
|
|
||||||
.presign_upload(&remote_key, Some(content_type))
|
|
||||||
.await?;
|
|
||||||
self
|
self
|
||||||
.client
|
.upload_config_json(&remote_key, &json, updated_group.updated_at)
|
||||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Update local group with new last_sync
|
// Update local group with new last_sync
|
||||||
|
|||||||
@@ -62,9 +62,9 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
|
|||||||
"**/BrowserMetrics*",
|
"**/BrowserMetrics*",
|
||||||
"**/.DS_Store",
|
"**/.DS_Store",
|
||||||
".donut-sync/**",
|
".donut-sync/**",
|
||||||
// Local-only marker recording when Wayfern last refreshed this profile's
|
// Orphaned local-only marker from earlier rollover-based fingerprint
|
||||||
// fingerprint. Each device decides its own refresh cadence, so syncing
|
// regeneration. Keep excluding it so any markers left on disk from
|
||||||
// this would cause one device's refresh to silence others.
|
// prior builds never get uploaded.
|
||||||
".last-fp-refresh",
|
".last-fp-refresh",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StatRequest {
|
pub struct StatRequest {
|
||||||
@@ -11,6 +12,11 @@ pub struct StatResponse {
|
|||||||
#[serde(rename = "lastModified")]
|
#[serde(rename = "lastModified")]
|
||||||
pub last_modified: Option<String>,
|
pub last_modified: Option<String>,
|
||||||
pub size: Option<u64>,
|
pub size: Option<u64>,
|
||||||
|
/// User-defined S3 object metadata (`x-amz-meta-*`), lowercased keys without
|
||||||
|
/// the prefix. `None` from older servers that don't return it. Used to read
|
||||||
|
/// `updated-at` for sync conflict resolution without downloading the body.
|
||||||
|
#[serde(default)]
|
||||||
|
pub metadata: Option<HashMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -20,6 +26,9 @@ pub struct PresignUploadRequest {
|
|||||||
pub content_type: Option<String>,
|
pub content_type: Option<String>,
|
||||||
#[serde(rename = "expiresIn")]
|
#[serde(rename = "expiresIn")]
|
||||||
pub expires_in: Option<u64>,
|
pub expires_in: Option<u64>,
|
||||||
|
/// Object metadata to sign into the presigned PUT (stored as `x-amz-meta-*`).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub metadata: Option<HashMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -27,6 +36,11 @@ pub struct PresignUploadResponse {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
#[serde(rename = "expiresAt")]
|
#[serde(rename = "expiresAt")]
|
||||||
pub expires_at: String,
|
pub expires_at: String,
|
||||||
|
/// The metadata the server actually signed into the URL. The client must send
|
||||||
|
/// exactly these as `x-amz-meta-*` headers on the PUT or S3 rejects it. `None`
|
||||||
|
/// from older servers → client sends no metadata headers (body-GET fallback).
|
||||||
|
#[serde(default)]
|
||||||
|
pub metadata: Option<HashMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ pub struct VpnConfig {
|
|||||||
pub sync_enabled: bool,
|
pub sync_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_sync: Option<u64>,
|
pub last_sync: Option<u64>,
|
||||||
|
/// Unix seconds of the last meaningful user edit. Source of truth for sync
|
||||||
|
/// conflict resolution (last-write-wins); bumped on config edits only.
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parsed WireGuard configuration
|
/// Parsed WireGuard configuration
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ struct StoredVpnConfig {
|
|||||||
sync_enabled: bool,
|
sync_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
last_sync: Option<u64>,
|
last_sync: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
updated_at: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// VPN storage manager with encryption
|
/// VPN storage manager with encryption
|
||||||
@@ -247,6 +249,7 @@ impl VpnStorage {
|
|||||||
last_used: config.last_used,
|
last_used: config.last_used,
|
||||||
sync_enabled: config.sync_enabled,
|
sync_enabled: config.sync_enabled,
|
||||||
last_sync: config.last_sync,
|
last_sync: config.last_sync,
|
||||||
|
updated_at: config.updated_at,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update existing or add new
|
// Update existing or add new
|
||||||
@@ -280,6 +283,7 @@ impl VpnStorage {
|
|||||||
last_used: stored.last_used,
|
last_used: stored.last_used,
|
||||||
sync_enabled: stored.sync_enabled,
|
sync_enabled: stored.sync_enabled,
|
||||||
last_sync: stored.last_sync,
|
last_sync: stored.last_sync,
|
||||||
|
updated_at: stored.updated_at,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +304,7 @@ impl VpnStorage {
|
|||||||
last_used: stored.last_used,
|
last_used: stored.last_used,
|
||||||
sync_enabled: stored.sync_enabled,
|
sync_enabled: stored.sync_enabled,
|
||||||
last_sync: stored.last_sync,
|
last_sync: stored.last_sync,
|
||||||
|
updated_at: stored.updated_at,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
)
|
)
|
||||||
@@ -356,6 +361,7 @@ impl VpnStorage {
|
|||||||
last_used: None,
|
last_used: None,
|
||||||
sync_enabled,
|
sync_enabled,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: Some(crate::proxy_manager::now_secs()),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.save_config(&config)?;
|
self.save_config(&config)?;
|
||||||
@@ -367,6 +373,7 @@ impl VpnStorage {
|
|||||||
pub fn update_config_name(&self, id: &str, new_name: &str) -> Result<VpnConfig, VpnError> {
|
pub fn update_config_name(&self, id: &str, new_name: &str) -> Result<VpnConfig, VpnError> {
|
||||||
let mut config = self.load_config(id)?;
|
let mut config = self.load_config(id)?;
|
||||||
config.name = new_name.to_string();
|
config.name = new_name.to_string();
|
||||||
|
config.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
self.save_config(&config)?;
|
self.save_config(&config)?;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
@@ -420,6 +427,7 @@ impl VpnStorage {
|
|||||||
last_used: None,
|
last_used: None,
|
||||||
sync_enabled,
|
sync_enabled,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: Some(crate::proxy_manager::now_secs()),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.save_config(&config)?;
|
self.save_config(&config)?;
|
||||||
@@ -463,6 +471,7 @@ mod tests {
|
|||||||
last_used: None,
|
last_used: None,
|
||||||
sync_enabled: false,
|
sync_enabled: false,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.save_config(&config).unwrap();
|
storage.save_config(&config).unwrap();
|
||||||
@@ -487,6 +496,7 @@ mod tests {
|
|||||||
last_used: None,
|
last_used: None,
|
||||||
sync_enabled: false,
|
sync_enabled: false,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let config2 = VpnConfig {
|
let config2 = VpnConfig {
|
||||||
@@ -498,6 +508,7 @@ mod tests {
|
|||||||
last_used: Some(3000),
|
last_used: Some(3000),
|
||||||
sync_enabled: false,
|
sync_enabled: false,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.save_config(&config1).unwrap();
|
storage.save_config(&config1).unwrap();
|
||||||
@@ -524,6 +535,7 @@ mod tests {
|
|||||||
last_used: None,
|
last_used: None,
|
||||||
sync_enabled: false,
|
sync_enabled: false,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.save_config(&config).unwrap();
|
storage.save_config(&config).unwrap();
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ pub struct WayfernLaunchResult {
|
|||||||
pub profilePath: Option<String>,
|
pub profilePath: Option<String>,
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
pub cdp_port: Option<u16>,
|
pub cdp_port: Option<u16>,
|
||||||
|
/// The fingerprint Wayfern actually applied, echoed back by
|
||||||
|
/// Wayfern.setFingerprint. It may be UPGRADED from the stored fingerprint
|
||||||
|
/// (e.g. when the stored one targets an older browser version). Internal
|
||||||
|
/// only — the caller persists it to the profile; never sent to the frontend.
|
||||||
|
#[serde(default, skip_serializing)]
|
||||||
|
pub used_fingerprint: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WayfernInstance {
|
struct WayfernInstance {
|
||||||
@@ -132,6 +138,46 @@ impl WayfernManager {
|
|||||||
fingerprint
|
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(
|
async fn wait_for_cdp_ready(
|
||||||
&self,
|
&self,
|
||||||
port: u16,
|
port: u16,
|
||||||
@@ -605,13 +651,30 @@ impl WayfernManager {
|
|||||||
"--disable-session-crashed-bubble".to_string(),
|
"--disable-session-crashed-bubble".to_string(),
|
||||||
"--hide-crash-restore-bubble".to_string(),
|
"--hide-crash-restore-bubble".to_string(),
|
||||||
"--disable-infobars".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(),
|
"--use-mock-keychain".to_string(),
|
||||||
"--password-store=basic".to_string(),
|
"--password-store=basic".to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if headless {
|
if headless {
|
||||||
args.push("--headless=new".to_string());
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -703,6 +766,7 @@ impl WayfernManager {
|
|||||||
log::info!("Found {} page targets", page_targets.len());
|
log::info!("Found {} page targets", page_targets.len());
|
||||||
|
|
||||||
// Apply fingerprint if configured
|
// Apply fingerprint if configured
|
||||||
|
let mut used_fingerprint: Option<String> = None;
|
||||||
if let Some(fingerprint_json) = &config.fingerprint {
|
if let Some(fingerprint_json) = &config.fingerprint {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Applying fingerprint to Wayfern browser, fingerprint length: {} chars",
|
"Applying fingerprint to Wayfern browser, fingerprint length: {} chars",
|
||||||
@@ -781,10 +845,30 @@ impl WayfernManager {
|
|||||||
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone())
|
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(result) => log::info!(
|
Ok(result) => {
|
||||||
"Successfully applied fingerprint to page target: {:?}",
|
log::info!(
|
||||||
result
|
"Successfully applied fingerprint to page target: {:?}",
|
||||||
),
|
result
|
||||||
|
);
|
||||||
|
// Wayfern.setFingerprint echoes back the fingerprint it actually
|
||||||
|
// used, which may be UPGRADED from what we sent (e.g. when the
|
||||||
|
// stored fingerprint targets an older browser version). Capture
|
||||||
|
// it once, from the first target that succeeds, so the caller can
|
||||||
|
// persist the upgraded value to the profile.
|
||||||
|
if used_fingerprint.is_none() {
|
||||||
|
// getFingerprint/setFingerprint wrap the object as
|
||||||
|
// { fingerprint: {...} }; tolerate a bare object too.
|
||||||
|
let fp = result.get("fingerprint").cloned().unwrap_or(result);
|
||||||
|
if fp.is_object() {
|
||||||
|
match serde_json::to_string(&Self::normalize_fingerprint(fp)) {
|
||||||
|
Ok(s) => used_fingerprint = Some(s),
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to serialize used fingerprint: {e}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(e) => log::error!("Failed to apply fingerprint to target: {e}"),
|
Err(e) => log::error!("Failed to apply fingerprint to target: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -849,6 +933,7 @@ impl WayfernManager {
|
|||||||
profilePath: Some(profile_path.to_string()),
|
profilePath: Some(profile_path.to_string()),
|
||||||
url: url.map(|s| s.to_string()),
|
url: url.map(|s| s.to_string()),
|
||||||
cdp_port: Some(port),
|
cdp_port: Some(port),
|
||||||
|
used_fingerprint,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -990,6 +1075,7 @@ impl WayfernManager {
|
|||||||
profilePath: instance.profile_path.clone(),
|
profilePath: instance.profile_path.clone(),
|
||||||
url: instance.url.clone(),
|
url: instance.url.clone(),
|
||||||
cdp_port: instance.cdp_port,
|
cdp_port: instance.cdp_port,
|
||||||
|
used_fingerprint: None,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -1032,6 +1118,7 @@ impl WayfernManager {
|
|||||||
profilePath: Some(found_profile_path),
|
profilePath: Some(found_profile_path),
|
||||||
url: None,
|
url: None,
|
||||||
cdp_port,
|
cdp_port,
|
||||||
|
used_fingerprint: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1168,3 +1255,72 @@ impl WayfernManager {
|
|||||||
lazy_static::lazy_static! {
|
lazy_static::lazy_static! {
|
||||||
static ref WAYFERN_MANAGER: WayfernManager = WayfernManager::new();
|
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",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Donut",
|
"productName": "Donut",
|
||||||
"version": "0.24.3",
|
"version": "0.26.0",
|
||||||
"identifier": "com.donutbrowser",
|
"identifier": "com.donutbrowser",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["app", "dmg", "nsis", "deb", "rpm", "appimage"],
|
"targets": ["app", "dmg", "nsis", "deb", "rpm", "appimage"],
|
||||||
"category": "Productivity",
|
"category": "Productivity",
|
||||||
"externalBin": ["binaries/donut-proxy", "binaries/donut-daemon"],
|
"externalBin": ["binaries/donut-proxy"],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
@@ -42,11 +42,11 @@
|
|||||||
"linux": {
|
"linux": {
|
||||||
"deb": {
|
"deb": {
|
||||||
"desktopTemplate": "donutbrowser.desktop",
|
"desktopTemplate": "donutbrowser.desktop",
|
||||||
"depends": ["xdg-utils", "libxdo3"]
|
"depends": ["xdg-utils", "libxdo3", "libayatana-appindicator3-1"]
|
||||||
},
|
},
|
||||||
"rpm": {
|
"rpm": {
|
||||||
"desktopTemplate": "donutbrowser.desktop",
|
"desktopTemplate": "donutbrowser.desktop",
|
||||||
"depends": ["xdg-utils", "libxdo"]
|
"depends": ["xdg-utils", "libxdo", "libayatana-appindicator-gtk3"]
|
||||||
},
|
},
|
||||||
"appimage": {
|
"appimage": {
|
||||||
"files": {
|
"files": {
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ fn test_vpn_storage_save_and_load() {
|
|||||||
last_used: None,
|
last_used: None,
|
||||||
sync_enabled: false,
|
sync_enabled: false,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let save_result = storage.save_config(&config);
|
let save_result = storage.save_config(&config);
|
||||||
@@ -174,6 +175,7 @@ fn test_vpn_storage_list() {
|
|||||||
last_used: None,
|
last_used: None,
|
||||||
sync_enabled: false,
|
sync_enabled: false,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
storage.save_config(&config).unwrap();
|
storage.save_config(&config).unwrap();
|
||||||
}
|
}
|
||||||
@@ -201,6 +203,7 @@ fn test_vpn_storage_delete() {
|
|||||||
last_used: None,
|
last_used: None,
|
||||||
sync_enabled: false,
|
sync_enabled: false,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.save_config(&config).unwrap();
|
storage.save_config(&config).unwrap();
|
||||||
@@ -489,6 +492,7 @@ fn new_test_vpn_config(name: &str, vpn_type: VpnType, config_data: String) -> Vp
|
|||||||
last_used: None,
|
last_used: None,
|
||||||
sync_enabled: false,
|
sync_enabled: false,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+148
-50
@@ -3,11 +3,14 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
||||||
|
import { useOnborda } from "onborda";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AccountPage } from "@/components/account-page";
|
import { AccountPage } from "@/components/account-page";
|
||||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||||
|
import { CamoufoxDeprecationDialog } from "@/components/camoufox-deprecation-dialog";
|
||||||
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
||||||
|
import { CloseConfirmDialog } from "@/components/close-confirm-dialog";
|
||||||
import { CommandPalette } from "@/components/command-palette";
|
import { CommandPalette } from "@/components/command-palette";
|
||||||
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
||||||
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
||||||
@@ -22,7 +25,7 @@ import { GroupManagementDialog } from "@/components/group-management-dialog";
|
|||||||
import HomeHeader from "@/components/home-header";
|
import HomeHeader from "@/components/home-header";
|
||||||
import { ImportProfileDialog } from "@/components/import-profile-dialog";
|
import { ImportProfileDialog } from "@/components/import-profile-dialog";
|
||||||
import { IntegrationsDialog } from "@/components/integrations-dialog";
|
import { IntegrationsDialog } from "@/components/integrations-dialog";
|
||||||
import { LaunchOnLoginDialog } from "@/components/launch-on-login-dialog";
|
import { ONBOARDING_TOUR } from "@/components/onboarding-provider";
|
||||||
import { PermissionDialog } from "@/components/permission-dialog";
|
import { PermissionDialog } from "@/components/permission-dialog";
|
||||||
import { ProfilesDataTable } from "@/components/profile-data-table";
|
import { ProfilesDataTable } from "@/components/profile-data-table";
|
||||||
import {
|
import {
|
||||||
@@ -39,7 +42,9 @@ import { ShortcutsPage } from "@/components/shortcuts-page";
|
|||||||
import { SyncAllDialog } from "@/components/sync-all-dialog";
|
import { SyncAllDialog } from "@/components/sync-all-dialog";
|
||||||
import { SyncConfigDialog } from "@/components/sync-config-dialog";
|
import { SyncConfigDialog } from "@/components/sync-config-dialog";
|
||||||
import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
|
import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
|
||||||
|
import { ThankYouDialog } from "@/components/thank-you-dialog";
|
||||||
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
|
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
|
||||||
|
import { WelcomeDialog } from "@/components/welcome-dialog";
|
||||||
import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog";
|
import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog";
|
||||||
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
|
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
|
||||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||||
@@ -55,6 +60,11 @@ import { useVersionUpdater } from "@/hooks/use-version-updater";
|
|||||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||||
import { translateBackendError } from "@/lib/backend-errors";
|
import { translateBackendError } from "@/lib/backend-errors";
|
||||||
|
import { getEntitlements } from "@/lib/entitlements";
|
||||||
|
import {
|
||||||
|
ONBOARDING_TOUR_FINISHED_EVENT,
|
||||||
|
setOnboardingActive,
|
||||||
|
} from "@/lib/onboarding-signal";
|
||||||
import {
|
import {
|
||||||
matchesGroupDigit,
|
matchesGroupDigit,
|
||||||
matchesShortcut,
|
matchesShortcut,
|
||||||
@@ -95,6 +105,95 @@ export default function Home() {
|
|||||||
error: profilesError,
|
error: profilesError,
|
||||||
} = useProfileEvents();
|
} = useProfileEvents();
|
||||||
|
|
||||||
|
// First-run onboarding tour (Onborda).
|
||||||
|
const { startOnborda, setCurrentStep, isOnbordaVisible, currentStep } =
|
||||||
|
useOnborda();
|
||||||
|
const onboardingHandledRef = useRef(false);
|
||||||
|
const [welcomeOpen, setWelcomeOpen] = useState(false);
|
||||||
|
const [thankYouOpen, setThankYouOpen] = useState(false);
|
||||||
|
// null = onboarding decision pending; false = not a first-run onboarding (run
|
||||||
|
// the normal permission checks); true = first-run onboarding, so the welcome
|
||||||
|
// flow drives permissions and the standalone permission dialog is suppressed.
|
||||||
|
const [firstRunOnboarding, setFirstRunOnboarding] = useState<boolean | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Welcome flow finished. Existing-profile users are done after the welcome +
|
||||||
|
// commercial-use steps; users with no profile yet continue into the in-app
|
||||||
|
// product tour that walks them through creating their first profile.
|
||||||
|
const handleWelcomeComplete = useCallback(() => {
|
||||||
|
setWelcomeOpen(false);
|
||||||
|
setFirstRunOnboarding(false);
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
startOnborda(ONBOARDING_TOUR);
|
||||||
|
}
|
||||||
|
}, [startOnborda, profiles.length]);
|
||||||
|
|
||||||
|
// The product tour finished (user clicked "Finish", not "Skip") → celebrate.
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setThankYouOpen(true);
|
||||||
|
window.addEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handler);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Suppress the global browser-download toasts while onboarding (welcome or
|
||||||
|
// tour) is active — the welcome dialog shows setup progress itself.
|
||||||
|
useEffect(() => {
|
||||||
|
setOnboardingActive(welcomeOpen || isOnbordaVisible);
|
||||||
|
}, [welcomeOpen, isOnbordaVisible]);
|
||||||
|
|
||||||
|
// While the tour is visible, keep the body pinned to the left. Onborda calls
|
||||||
|
// scrollIntoView({ inline: "center" }) on the highlighted element; because the
|
||||||
|
// body is overflow-hidden it can still be scrolled programmatically, which
|
||||||
|
// would shove the whole app (rail and all) sideways with no way to scroll
|
||||||
|
// back. The profile table keeps its own scroll container, untouched here.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOnbordaVisible) return;
|
||||||
|
const pin = () => {
|
||||||
|
if (document.body.scrollLeft !== 0) document.body.scrollLeft = 0;
|
||||||
|
if (document.documentElement.scrollLeft !== 0)
|
||||||
|
document.documentElement.scrollLeft = 0;
|
||||||
|
};
|
||||||
|
pin();
|
||||||
|
window.addEventListener("scroll", pin, true);
|
||||||
|
return () => window.removeEventListener("scroll", pin, true);
|
||||||
|
}, [isOnbordaVisible]);
|
||||||
|
|
||||||
|
// On the very first launch, always show the welcome + commercial-use steps
|
||||||
|
// (one-shot: the backend flag is set immediately so it can't trigger again).
|
||||||
|
// The welcome dialog itself decides whether to continue into the browser
|
||||||
|
// download + profile-creation flow — only when the user has no profile yet.
|
||||||
|
useEffect(() => {
|
||||||
|
if (profilesLoading || onboardingHandledRef.current) return;
|
||||||
|
onboardingHandledRef.current = true;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const completed = await invoke<boolean>("get_onboarding_completed");
|
||||||
|
if (completed) {
|
||||||
|
setFirstRunOnboarding(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await invoke("complete_onboarding");
|
||||||
|
setFirstRunOnboarding(true);
|
||||||
|
setWelcomeOpen(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Onboarding init failed:", err);
|
||||||
|
setFirstRunOnboarding(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [profilesLoading]);
|
||||||
|
|
||||||
|
// Advance from the "create a profile" step to the "DNS blocking" step as soon
|
||||||
|
// as the user's first profile exists (its DNS dropdown is now in the DOM).
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOnbordaVisible && currentStep === 0 && profiles.length > 0) {
|
||||||
|
// Small delay so the new profile row (and its DNS dropdown target) has
|
||||||
|
// mounted before Onborda re-points at it.
|
||||||
|
setCurrentStep(1, 300);
|
||||||
|
}
|
||||||
|
}, [isOnbordaVisible, currentStep, profiles.length, setCurrentStep]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
groups: groupsData,
|
groups: groupsData,
|
||||||
isLoading: groupsLoading,
|
isLoading: groupsLoading,
|
||||||
@@ -128,10 +227,7 @@ export default function Home() {
|
|||||||
|
|
||||||
// Cloud auth for cross-OS unlock
|
// Cloud auth for cross-OS unlock
|
||||||
const { user: cloudUser } = useCloudAuth();
|
const { user: cloudUser } = useCloudAuth();
|
||||||
const crossOsUnlocked =
|
const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints;
|
||||||
cloudUser?.plan !== "free" &&
|
|
||||||
(cloudUser?.subscriptionStatus === "active" ||
|
|
||||||
cloudUser?.planPeriod === "lifetime");
|
|
||||||
|
|
||||||
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
|
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@@ -214,8 +310,6 @@ export default function Home() {
|
|||||||
const [passwordDialogMode, setPasswordDialogMode] =
|
const [passwordDialogMode, setPasswordDialogMode] =
|
||||||
useState<PasswordDialogMode>("set");
|
useState<PasswordDialogMode>("set");
|
||||||
const pendingLaunchAfterUnlockRef = useRef<BrowserProfile | null>(null);
|
const pendingLaunchAfterUnlockRef = useRef<BrowserProfile | null>(null);
|
||||||
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
|
|
||||||
const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false);
|
|
||||||
const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false);
|
const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false);
|
||||||
const [windowResizeWarningBrowserType, setWindowResizeWarningBrowserType] =
|
const [windowResizeWarningBrowserType, setWindowResizeWarningBrowserType] =
|
||||||
useState<string | undefined>(undefined);
|
useState<string | undefined>(undefined);
|
||||||
@@ -545,24 +639,6 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, [handleUrlOpen, hasCheckedStartupUrl]);
|
}, [handleUrlOpen, hasCheckedStartupUrl]);
|
||||||
|
|
||||||
const checkStartupPrompt = useCallback(async () => {
|
|
||||||
// Only check once during app startup to prevent reopening after dismissing notifications
|
|
||||||
if (hasCheckedStartupPrompt) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const shouldShow = await invoke<boolean>(
|
|
||||||
"should_show_launch_on_login_prompt",
|
|
||||||
);
|
|
||||||
if (shouldShow) {
|
|
||||||
setLaunchOnLoginDialogOpen(true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to check startup prompt:", error);
|
|
||||||
} finally {
|
|
||||||
setHasCheckedStartupPrompt(true);
|
|
||||||
}
|
|
||||||
}, [hasCheckedStartupPrompt]);
|
|
||||||
|
|
||||||
// Handle profile errors from useProfileEvents hook
|
// Handle profile errors from useProfileEvents hook
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (profilesError) {
|
if (profilesError) {
|
||||||
@@ -795,9 +871,12 @@ export default function Home() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
showErrorToast(
|
showErrorToast(
|
||||||
t("errors.createProfileFailed", {
|
t("errors.createProfileFailed", {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: translateBackendError(t, error),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// Rethrow so the create dialog keeps itself open (its own handler
|
||||||
|
// skips closing on error), letting the user fix the proxy/VPN and retry.
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectedGroupId, t],
|
[selectedGroupId, t],
|
||||||
@@ -1088,11 +1167,14 @@ export default function Home() {
|
|||||||
profileId: profile.id,
|
profileId: profile.id,
|
||||||
syncMode: enabling ? "Regular" : "Disabled",
|
syncMode: enabling ? "Regular" : "Disabled",
|
||||||
});
|
});
|
||||||
showSuccessToast(enabling ? "Sync enabled" : "Sync disabled", {
|
showSuccessToast(
|
||||||
description: enabling
|
t(enabling ? "sync.enabledToast" : "sync.disabledToast"),
|
||||||
? "Profile sync has been enabled"
|
{
|
||||||
: "Profile sync has been disabled",
|
description: t(
|
||||||
});
|
enabling ? "sync.enabledDescription" : "sync.disabledDescription",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to toggle sync:", error);
|
console.error("Failed to toggle sync:", error);
|
||||||
showErrorToast(t("errors.updateSyncSettingsFailed"));
|
showErrorToast(t("errors.updateSyncSettingsFailed"));
|
||||||
@@ -1189,9 +1271,6 @@ export default function Home() {
|
|||||||
}, [profiles, t]);
|
}, [profiles, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check for startup default browser prompt
|
|
||||||
void checkStartupPrompt();
|
|
||||||
|
|
||||||
// Listen for URL open events and get cleanup function
|
// Listen for URL open events and get cleanup function
|
||||||
const setupListeners = async () => {
|
const setupListeners = async () => {
|
||||||
const cleanup = await listenForUrlEvents();
|
const cleanup = await listenForUrlEvents();
|
||||||
@@ -1234,7 +1313,6 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
checkStartupPrompt,
|
|
||||||
listenForUrlEvents,
|
listenForUrlEvents,
|
||||||
checkCurrentUrl,
|
checkCurrentUrl,
|
||||||
checkMissingBinaries,
|
checkMissingBinaries,
|
||||||
@@ -1249,6 +1327,7 @@ export default function Home() {
|
|||||||
let unlistenStarted: (() => void) | undefined;
|
let unlistenStarted: (() => void) | undefined;
|
||||||
let unlistenProgress: (() => void) | undefined;
|
let unlistenProgress: (() => void) | undefined;
|
||||||
let unlistenCompleted: (() => void) | undefined;
|
let unlistenCompleted: (() => void) | undefined;
|
||||||
|
let unlistenWayfernBlocked: (() => void) | undefined;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
unlistenRequired = await listen(
|
unlistenRequired = await listen(
|
||||||
@@ -1310,6 +1389,16 @@ export default function Home() {
|
|||||||
duration: 5000,
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1317,6 +1406,7 @@ export default function Home() {
|
|||||||
unlistenStarted?.();
|
unlistenStarted?.();
|
||||||
unlistenProgress?.();
|
unlistenProgress?.();
|
||||||
unlistenCompleted?.();
|
unlistenCompleted?.();
|
||||||
|
unlistenWayfernBlocked?.();
|
||||||
};
|
};
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
@@ -1336,11 +1426,13 @@ export default function Home() {
|
|||||||
showToast({
|
showToast({
|
||||||
id: "browser-support-ending-warning",
|
id: "browser-support-ending-warning",
|
||||||
type: "error",
|
type: "error",
|
||||||
title: "Browser support ending soon",
|
title: t("browserSupport.endingSoonTitle"),
|
||||||
description: `Support for the following profiles will be removed on March 15, 2026: ${unsupportedNames}. Please migrate to Wayfern or Camoufox profiles.`,
|
description: t("browserSupport.endingSoonDescription", {
|
||||||
|
profiles: unsupportedNames,
|
||||||
|
}),
|
||||||
duration: 15000,
|
duration: 15000,
|
||||||
action: {
|
action: {
|
||||||
label: "Learn more",
|
label: t("common.buttons.learnMore"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
const event = new CustomEvent("url-open-request", {
|
const event = new CustomEvent("url-open-request", {
|
||||||
detail: "https://github.com/zhom/donutbrowser/discussions",
|
detail: "https://github.com/zhom/donutbrowser/discussions",
|
||||||
@@ -1350,7 +1442,7 @@ export default function Home() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [profiles]);
|
}, [profiles, t]);
|
||||||
|
|
||||||
// Re-check Wayfern terms when a browser download completes
|
// Re-check Wayfern terms when a browser download completes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1371,12 +1463,14 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
}, [checkTerms]);
|
}, [checkTerms]);
|
||||||
|
|
||||||
// Check permissions when they are initialized
|
// Check permissions when they are initialized. During first-run onboarding
|
||||||
|
// the welcome flow requests permissions, so the standalone dialog is deferred
|
||||||
|
// until we know this isn't a first-run onboarding.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isInitialized) {
|
if (isInitialized && firstRunOnboarding === false) {
|
||||||
checkAllPermissions();
|
checkAllPermissions();
|
||||||
}
|
}
|
||||||
}, [isInitialized, checkAllPermissions]);
|
}, [isInitialized, firstRunOnboarding, checkAllPermissions]);
|
||||||
|
|
||||||
// Check self-hosted sync config on mount and when cloud user changes
|
// Check self-hosted sync config on mount and when cloud user changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1431,6 +1525,8 @@ export default function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-background font-(family-name:--font-geist-sans)">
|
<div className="flex flex-col h-screen bg-background font-(family-name:--font-geist-sans)">
|
||||||
|
<CloseConfirmDialog />
|
||||||
|
<CamoufoxDeprecationDialog profiles={profiles} />
|
||||||
<HomeHeader
|
<HomeHeader
|
||||||
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
@@ -1645,6 +1741,16 @@ export default function Home() {
|
|||||||
onPermissionGranted={checkNextPermission}
|
onPermissionGranted={checkNextPermission}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<WelcomeDialog
|
||||||
|
isOpen={welcomeOpen}
|
||||||
|
needsSetup={profiles.length === 0}
|
||||||
|
onComplete={handleWelcomeComplete}
|
||||||
|
/>
|
||||||
|
<ThankYouDialog
|
||||||
|
isOpen={thankYouOpen}
|
||||||
|
onClose={() => setThankYouOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<CloneProfileDialog
|
<CloneProfileDialog
|
||||||
isOpen={!!cloneProfile}
|
isOpen={!!cloneProfile}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -1849,14 +1955,6 @@ export default function Home() {
|
|||||||
onClose={checkTrialStatus}
|
onClose={checkTrialStatus}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Launch on Login Dialog - shown on every startup until enabled or declined */}
|
|
||||||
<LaunchOnLoginDialog
|
|
||||||
isOpen={launchOnLoginDialogOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setLaunchOnLoginDialogOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<WindowResizeWarningDialog
|
<WindowResizeWarningDialog
|
||||||
isOpen={windowResizeWarningOpen}
|
isOpen={windowResizeWarningOpen}
|
||||||
browserType={windowResizeWarningBrowserType}
|
browserType={windowResizeWarningBrowserType}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||||
import { translateBackendError } from "@/lib/backend-errors";
|
import { translateBackendError } from "@/lib/backend-errors";
|
||||||
|
import { getEntitlements } from "@/lib/entitlements";
|
||||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||||
import type { SyncSettings } from "@/types";
|
import type { SyncSettings } from "@/types";
|
||||||
|
|
||||||
@@ -280,9 +281,40 @@ export function AccountPage({
|
|||||||
<p className="mt-0.5">{user.planPeriod}</p>
|
<p className="mt-0.5">{user.planPeriod}</p>
|
||||||
</div>
|
</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">
|
||||||
|
{t("account.fields.device")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5">
|
||||||
|
{t("account.deviceOrdinal", {
|
||||||
|
ordinal: user.deviceOrdinal,
|
||||||
|
count: user.deviceCount ?? user.deviceOrdinal,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isLoggedIn &&
|
||||||
|
user &&
|
||||||
|
getEntitlements(user).browserAutomation &&
|
||||||
|
user.isPrimaryDevice === false && (
|
||||||
|
<p className="text-xs text-warning">
|
||||||
|
{t("account.automationPrimaryOnly")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isLoggedIn &&
|
||||||
|
user &&
|
||||||
|
getEntitlements(user).browserAutomation &&
|
||||||
|
user.isPrimaryDevice === true &&
|
||||||
|
(user.deviceCount ?? 1) > 1 && (
|
||||||
|
<p className="text-xs text-success">
|
||||||
|
{t("account.automationActiveHere")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function AppUpdateToast({
|
|||||||
return (
|
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="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">
|
<div className="mr-3 mt-0.5">
|
||||||
<LuCheckCheck className="flex-shrink-0 size-5" />
|
<LuCheckCheck className="shrink-0 size-5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { I18nProvider } from "@/components/i18n-provider";
|
import { I18nProvider } from "@/components/i18n-provider";
|
||||||
|
import { OnboardingProvider } from "@/components/onboarding-provider";
|
||||||
import { CustomThemeProvider } from "@/components/theme-provider";
|
import { CustomThemeProvider } from "@/components/theme-provider";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
@@ -17,7 +18,9 @@ export function ClientProviders({ children }: { children: React.ReactNode }) {
|
|||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<CustomThemeProvider>
|
<CustomThemeProvider>
|
||||||
<WindowDragArea />
|
<WindowDragArea />
|
||||||
<TooltipProvider>{children}</TooltipProvider>
|
<TooltipProvider>
|
||||||
|
<OnboardingProvider>{children}</OnboardingProvider>
|
||||||
|
</TooltipProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</CustomThemeProvider>
|
</CustomThemeProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { RippleButton } from "./ui/ripple";
|
||||||
|
|
||||||
|
export function CloseConfirmDialog() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unlistenPromise = listen("close-confirm-requested", () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
void unlistenPromise.then((u) => {
|
||||||
|
u();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The native tray menu is built in Rust and cannot read the active language,
|
||||||
|
// so push localized labels to it on mount and whenever the language changes.
|
||||||
|
useEffect(() => {
|
||||||
|
const syncTrayMenu = () => {
|
||||||
|
void invoke("update_tray_menu", {
|
||||||
|
showLabel: t("tray.show"),
|
||||||
|
quitLabel: t("tray.quit"),
|
||||||
|
}).catch(() => {
|
||||||
|
// Tray is desktop-only; ignore on platforms without one.
|
||||||
|
});
|
||||||
|
};
|
||||||
|
syncTrayMenu();
|
||||||
|
i18n.on("languageChanged", syncTrayMenu);
|
||||||
|
return () => {
|
||||||
|
i18n.off("languageChanged", syncTrayMenu);
|
||||||
|
};
|
||||||
|
}, [t, i18n]);
|
||||||
|
|
||||||
|
const handleMinimize = async () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
try {
|
||||||
|
await invoke("hide_to_tray");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to hide to tray:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuit = async () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
try {
|
||||||
|
await invoke("confirm_quit");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to quit app:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("closeConfirm.title")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("closeConfirm.description")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<RippleButton
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
void handleMinimize();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("closeConfirm.minimize")}
|
||||||
|
</RippleButton>
|
||||||
|
<RippleButton
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
void handleQuit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("closeConfirm.quit")}
|
||||||
|
</RippleButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,11 +11,9 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { GoPlus } from "react-icons/go";
|
import { GoPlus } from "react-icons/go";
|
||||||
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
|
import { LuCheck, LuChevronsUpDown, LuLoaderCircle } from "react-icons/lu";
|
||||||
import { LoadingButton } from "@/components/loading-button";
|
import { LoadingButton } from "@/components/loading-button";
|
||||||
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
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 { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||||
import { getBrowserIcon } from "@/lib/browser-utils";
|
import { getBrowserIcon } from "@/lib/browser-utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type {
|
import type { BrowserReleaseTypes, WayfernConfig, WayfernOS } from "@/types";
|
||||||
BrowserReleaseTypes,
|
|
||||||
CamoufoxConfig,
|
|
||||||
CamoufoxOS,
|
|
||||||
WayfernConfig,
|
|
||||||
WayfernOS,
|
|
||||||
} from "@/types";
|
|
||||||
|
|
||||||
const getCurrentOS = (): CamoufoxOS => {
|
const getCurrentOS = (): WayfernOS => {
|
||||||
if (typeof navigator === "undefined") return "linux";
|
if (typeof navigator === "undefined") return "linux";
|
||||||
const platform = navigator.platform.toLowerCase();
|
const platform = navigator.platform.toLowerCase();
|
||||||
if (platform.includes("win")) return "windows";
|
if (platform.includes("win")) return "windows";
|
||||||
@@ -86,7 +78,6 @@ interface CreateProfileDialogProps {
|
|||||||
releaseType: string;
|
releaseType: string;
|
||||||
proxyId?: string;
|
proxyId?: string;
|
||||||
vpnId?: string;
|
vpnId?: string;
|
||||||
camoufoxConfig?: CamoufoxConfig;
|
|
||||||
wayfernConfig?: WayfernConfig;
|
wayfernConfig?: WayfernConfig;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
extensionGroupId?: string;
|
extensionGroupId?: string;
|
||||||
@@ -105,10 +96,6 @@ interface BrowserOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const browserOptions: BrowserOption[] = [
|
const browserOptions: BrowserOption[] = [
|
||||||
{
|
|
||||||
value: "camoufox",
|
|
||||||
label: "Camoufox",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: "wayfern",
|
value: "wayfern",
|
||||||
label: "Wayfern",
|
label: "Wayfern",
|
||||||
@@ -126,28 +113,24 @@ export function CreateProfileDialog({
|
|||||||
const proxyListboxIdAntiDetect = useId();
|
const proxyListboxIdAntiDetect = useId();
|
||||||
const proxyListboxIdRegular = useId();
|
const proxyListboxIdRegular = useId();
|
||||||
const [profileName, setProfileName] = useState("");
|
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<
|
const [currentStep, setCurrentStep] = useState<
|
||||||
"browser-selection" | "browser-config"
|
"browser-selection" | "browser-config"
|
||||||
>("browser-selection");
|
>("browser-config");
|
||||||
const [activeTab, setActiveTab] = useState("anti-detect");
|
const [activeTab, setActiveTab] = useState("anti-detect");
|
||||||
|
|
||||||
// Browser selection states
|
// Browser selection states. Defaults to Wayfern — the only creatable browser.
|
||||||
const [selectedBrowser, setSelectedBrowser] =
|
const [selectedBrowser, setSelectedBrowser] =
|
||||||
useState<BrowserTypeString | null>(null);
|
useState<BrowserTypeString>("wayfern");
|
||||||
const [selectedProxyId, setSelectedProxyId] = useState<string>();
|
const [selectedProxyId, setSelectedProxyId] = useState<string>();
|
||||||
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
|
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
|
||||||
const [dnsBlocklist, setDnsBlocklist] = useState<string>("");
|
const [dnsBlocklist, setDnsBlocklist] = useState<string>("");
|
||||||
const [launchHook, setLaunchHook] = useState("");
|
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
|
// Wayfern anti-detect states
|
||||||
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>(() => ({
|
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
|
// Handle browser selection from the initial screen
|
||||||
@@ -156,22 +139,23 @@ export function CreateProfileDialog({
|
|||||||
setCurrentStep("browser-config");
|
setCurrentStep("browser-config");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle back button
|
// Reset the form fields without leaving the Wayfern config step — Camoufox is
|
||||||
const handleBack = () => {
|
// deprecated, so there is no browser-selection screen to go back to.
|
||||||
setCurrentStep("browser-selection");
|
const resetForm = () => {
|
||||||
setSelectedBrowser(null);
|
setSelectedBrowser("wayfern");
|
||||||
setProfileName("");
|
setProfileName("");
|
||||||
setSelectedProxyId(undefined);
|
setSelectedProxyId(undefined);
|
||||||
setLaunchHook("");
|
setLaunchHook("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle back button
|
||||||
|
const handleBack = () => {
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
const handleTabChange = (value: string) => {
|
const handleTabChange = (value: string) => {
|
||||||
setActiveTab(value);
|
setActiveTab(value);
|
||||||
setCurrentStep("browser-selection");
|
resetForm();
|
||||||
setSelectedBrowser(null);
|
|
||||||
setProfileName("");
|
|
||||||
setSelectedProxyId(undefined);
|
|
||||||
setLaunchHook("");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
|
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
|
||||||
@@ -307,12 +291,15 @@ export function CreateProfileDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
void loadSupportedBrowsers();
|
void loadSupportedBrowsers();
|
||||||
|
// Load downloaded Wayfern versions up front so the availability gate is
|
||||||
|
// accurate. Camoufox is deprecated and no longer creatable.
|
||||||
|
void loadDownloadedVersions("wayfern");
|
||||||
// Load release types when a browser is selected
|
// Load release types when a browser is selected
|
||||||
if (selectedBrowser) {
|
if (selectedBrowser) {
|
||||||
void loadReleaseTypes(selectedBrowser);
|
void loadReleaseTypes(selectedBrowser);
|
||||||
}
|
}
|
||||||
// Check and download GeoIP database if needed for Camoufox or Wayfern
|
// Wayfern needs the GeoIP database for fingerprint generation.
|
||||||
if (selectedBrowser === "camoufox" || selectedBrowser === "wayfern") {
|
if (selectedBrowser === "wayfern") {
|
||||||
void checkAndDownloadGeoIPDatabase();
|
void checkAndDownloadGeoIPDatabase();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,6 +307,7 @@ export function CreateProfileDialog({
|
|||||||
isOpen,
|
isOpen,
|
||||||
loadSupportedBrowsers,
|
loadSupportedBrowsers,
|
||||||
loadReleaseTypes,
|
loadReleaseTypes,
|
||||||
|
loadDownloadedVersions,
|
||||||
checkAndDownloadGeoIPDatabase,
|
checkAndDownloadGeoIPDatabase,
|
||||||
selectedBrowser,
|
selectedBrowser,
|
||||||
]);
|
]);
|
||||||
@@ -405,72 +393,41 @@ export function CreateProfileDialog({
|
|||||||
const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId;
|
const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId;
|
||||||
const resolvedVpnId =
|
const resolvedVpnId =
|
||||||
isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined;
|
isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined;
|
||||||
|
|
||||||
const passwordToSet =
|
const passwordToSet =
|
||||||
enablePassword && !ephemeral && password.length >= PASSWORD_MIN_LEN
|
enablePassword && !ephemeral && password.length >= PASSWORD_MIN_LEN
|
||||||
? password
|
? password
|
||||||
: undefined;
|
: undefined;
|
||||||
try {
|
try {
|
||||||
if (activeTab === "anti-detect") {
|
if (activeTab === "anti-detect") {
|
||||||
// Anti-detect browser - check if Wayfern or Camoufox is selected
|
// Camoufox is deprecated — only Wayfern anti-detect profiles are created.
|
||||||
if (selectedBrowser === "wayfern") {
|
const bestWayfernVersion = getCreatableVersion("wayfern");
|
||||||
const bestWayfernVersion = getCreatableVersion("wayfern");
|
if (!bestWayfernVersion) {
|
||||||
if (!bestWayfernVersion) {
|
console.error("No Wayfern version available");
|
||||||
console.error("No Wayfern version available");
|
return;
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
} else {
|
||||||
// Regular browser
|
// Regular browser
|
||||||
if (!selectedBrowser) {
|
if (!selectedBrowser) {
|
||||||
@@ -513,22 +470,19 @@ export function CreateProfileDialog({
|
|||||||
// Cancel any ongoing loading
|
// Cancel any ongoing loading
|
||||||
loadingBrowserRef.current = null;
|
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("");
|
setProfileName("");
|
||||||
setCurrentStep("browser-selection");
|
setCurrentStep("browser-config");
|
||||||
setActiveTab("anti-detect");
|
setActiveTab("anti-detect");
|
||||||
setSelectedBrowser(null);
|
setSelectedBrowser("wayfern");
|
||||||
setSelectedProxyId(undefined);
|
setSelectedProxyId(undefined);
|
||||||
setLaunchHook("");
|
setLaunchHook("");
|
||||||
setReleaseTypes({});
|
setReleaseTypes({});
|
||||||
setIsLoadingReleaseTypes(false);
|
setIsLoadingReleaseTypes(false);
|
||||||
setReleaseTypesError(null);
|
setReleaseTypesError(null);
|
||||||
setCamoufoxConfig({
|
|
||||||
geoip: true, // Reset to automatic geoip
|
|
||||||
os: getCurrentOS(), // Reset to current OS
|
|
||||||
});
|
|
||||||
setWayfernConfig({
|
setWayfernConfig({
|
||||||
os: getCurrentOS() as WayfernOS, // Reset to current OS
|
os: getCurrentOS(), // Reset to current OS
|
||||||
});
|
});
|
||||||
setEphemeral(false);
|
setEphemeral(false);
|
||||||
setEnablePassword(false);
|
setEnablePassword(false);
|
||||||
@@ -538,10 +492,6 @@ export function CreateProfileDialog({
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateCamoufoxConfig = (key: keyof CamoufoxConfig, value: unknown) => {
|
|
||||||
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateWayfernConfig = (key: keyof WayfernConfig, value: unknown) => {
|
const updateWayfernConfig = (key: keyof WayfernConfig, value: unknown) => {
|
||||||
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
||||||
};
|
};
|
||||||
@@ -585,7 +535,7 @@ export function CreateProfileDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent className="w-[380px] max-w-[380px] max-h-[90vh] flex flex-col">
|
<DialogContent className="w-[380px] max-w-[380px] max-h-[90vh] flex flex-col">
|
||||||
<DialogHeader className="flex-shrink-0">
|
<DialogHeader className="shrink-0">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{currentStep === "browser-selection"
|
{currentStep === "browser-selection"
|
||||||
? t("createProfile.title")
|
? t("createProfile.title")
|
||||||
@@ -618,52 +568,42 @@ export function CreateProfileDialog({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleBrowserSelect("wayfern");
|
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 gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<div className="flex justify-center items-center size-8">
|
<div className="flex justify-center items-center size-8">
|
||||||
{(() => {
|
{isBrowserCurrentlyDownloading("wayfern") ? (
|
||||||
const IconComponent = getBrowserIcon("wayfern");
|
<LuLoaderCircle className="size-6 animate-spin" />
|
||||||
return IconComponent ? (
|
) : (
|
||||||
<IconComponent className="size-6" />
|
(() => {
|
||||||
) : null;
|
const IconComponent = getBrowserIcon("wayfern");
|
||||||
})()}
|
return IconComponent ? (
|
||||||
|
<IconComponent className="size-6" />
|
||||||
|
) : null;
|
||||||
|
})()
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{t("createProfile.chromiumLabel")}
|
{t("createProfile.chromiumLabel")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t("createProfile.chromiumSubtitle")}
|
{isBrowserCurrentlyDownloading("wayfern")
|
||||||
|
? t("createProfile.downloadingSubtitle")
|
||||||
|
: t("createProfile.chromiumSubtitle")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Camoufox (Firefox) - Second */}
|
{/* Camoufox is deprecated — no longer offered for new
|
||||||
<Button
|
profiles. Only Wayfern can be created. */}
|
||||||
onClick={() => {
|
|
||||||
handleBrowserSelect("camoufox");
|
{!getCreatableVersion("wayfern") && (
|
||||||
}}
|
<p className="pt-2 text-sm text-center text-muted-foreground">
|
||||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
{t("createProfile.browsersDownloading")}
|
||||||
variant="outline"
|
</p>
|
||||||
>
|
)}
|
||||||
<div className="flex justify-center items-center size-8">
|
|
||||||
{(() => {
|
|
||||||
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">
|
|
||||||
{t("createProfile.firefoxSubtitle")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -867,7 +807,7 @@ export function CreateProfileDialog({
|
|||||||
{!isLoadingReleaseTypes &&
|
{!isLoadingReleaseTypes &&
|
||||||
!releaseTypesError &&
|
!releaseTypesError &&
|
||||||
!isBrowserCurrentlyDownloading("wayfern") &&
|
!isBrowserCurrentlyDownloading("wayfern") &&
|
||||||
!isBrowserVersionAvailable("wayfern") &&
|
!getCreatableVersion("wayfern") &&
|
||||||
getBestAvailableVersion("wayfern") && (
|
getBestAvailableVersion("wayfern") && (
|
||||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -899,17 +839,53 @@ export function CreateProfileDialog({
|
|||||||
{!isLoadingReleaseTypes &&
|
{!isLoadingReleaseTypes &&
|
||||||
!releaseTypesError &&
|
!releaseTypesError &&
|
||||||
!isBrowserCurrentlyDownloading("wayfern") &&
|
!isBrowserCurrentlyDownloading("wayfern") &&
|
||||||
isBrowserVersionAvailable("wayfern") && (
|
getCreatableVersion("wayfern") && (
|
||||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||||
✓{" "}
|
✓{" "}
|
||||||
{t("createProfile.version.available", {
|
{t("createProfile.version.available", {
|
||||||
browser: "Wayfern",
|
browser: "Wayfern",
|
||||||
version:
|
version:
|
||||||
getBestAvailableVersion("wayfern")
|
getCreatableVersion("wayfern")?.version,
|
||||||
?.version,
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isLoadingReleaseTypes &&
|
||||||
|
!releaseTypesError &&
|
||||||
|
!isBrowserCurrentlyDownloading("wayfern") &&
|
||||||
|
getCreatableVersion("wayfern") &&
|
||||||
|
!isBrowserVersionAvailable("wayfern") &&
|
||||||
|
getBestAvailableVersion("wayfern") && (
|
||||||
|
<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: "Wayfern",
|
||||||
|
version:
|
||||||
|
getBestAvailableVersion("wayfern")
|
||||||
|
?.version,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<LoadingButton
|
||||||
|
onClick={() => {
|
||||||
|
void handleDownload("wayfern");
|
||||||
|
}}
|
||||||
|
isLoading={isBrowserCurrentlyDownloading(
|
||||||
|
"wayfern",
|
||||||
|
)}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isBrowserCurrentlyDownloading(
|
||||||
|
"wayfern",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isBrowserCurrentlyDownloading("wayfern")
|
||||||
|
? t("common.buttons.downloading")
|
||||||
|
: t("common.buttons.download")}
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isBrowserCurrentlyDownloading("wayfern") && (
|
{isBrowserCurrentlyDownloading("wayfern") && (
|
||||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||||
{t("createProfile.version.downloading", {
|
{t("createProfile.version.downloading", {
|
||||||
@@ -927,131 +903,14 @@ export function CreateProfileDialog({
|
|||||||
crossOsUnlocked={crossOsUnlocked}
|
crossOsUnlocked={crossOsUnlocked}
|
||||||
limitedMode={!crossOsUnlocked}
|
limitedMode={!crossOsUnlocked}
|
||||||
profileVersion={
|
profileVersion={
|
||||||
getBestAvailableVersion("wayfern")?.version
|
getCreatableVersion("wayfern")?.version
|
||||||
}
|
}
|
||||||
profileBrowser="wayfern"
|
profileBrowser="wayfern"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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") &&
|
|
||||||
!isBrowserVersionAvailable("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") &&
|
|
||||||
isBrowserVersionAvailable("camoufox") && (
|
|
||||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
|
||||||
✓{" "}
|
|
||||||
{t("createProfile.version.available", {
|
|
||||||
browser: "Camoufox",
|
|
||||||
version:
|
|
||||||
getBestAvailableVersion("camoufox")
|
|
||||||
?.version,
|
|
||||||
})}
|
|
||||||
</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={
|
|
||||||
getBestAvailableVersion("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">
|
<div className="space-y-4">
|
||||||
{selectedBrowser && (
|
{selectedBrowser && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -1077,7 +936,7 @@ export function CreateProfileDialog({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
Retry
|
{t("common.buttons.retry")}
|
||||||
</RippleButton>
|
</RippleButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1086,7 +945,7 @@ export function CreateProfileDialog({
|
|||||||
!isBrowserCurrentlyDownloading(
|
!isBrowserCurrentlyDownloading(
|
||||||
selectedBrowser,
|
selectedBrowser,
|
||||||
) &&
|
) &&
|
||||||
!isBrowserVersionAvailable(selectedBrowser) &&
|
!getCreatableVersion(selectedBrowser) &&
|
||||||
getBestAvailableVersion(selectedBrowser) && (
|
getBestAvailableVersion(selectedBrowser) && (
|
||||||
<div className="flex gap-3 items-center">
|
<div className="flex gap-3 items-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -1122,18 +981,15 @@ export function CreateProfileDialog({
|
|||||||
!isBrowserCurrentlyDownloading(
|
!isBrowserCurrentlyDownloading(
|
||||||
selectedBrowser,
|
selectedBrowser,
|
||||||
) &&
|
) &&
|
||||||
isBrowserVersionAvailable(
|
getCreatableVersion(selectedBrowser) && (
|
||||||
selectedBrowser,
|
|
||||||
) && (
|
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
✓{" "}
|
✓{" "}
|
||||||
{t(
|
{t(
|
||||||
"createProfile.version.latestAvailable",
|
"createProfile.version.latestAvailable",
|
||||||
{
|
{
|
||||||
version:
|
version:
|
||||||
getBestAvailableVersion(
|
getCreatableVersion(selectedBrowser)
|
||||||
selectedBrowser,
|
?.version,
|
||||||
)?.version,
|
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1432,7 +1288,7 @@ export function CreateProfileDialog({
|
|||||||
<div className="flex gap-3 items-center">
|
<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="size-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Fetching available versions...
|
{t("createProfile.version.fetching")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1458,7 +1314,7 @@ export function CreateProfileDialog({
|
|||||||
!isBrowserCurrentlyDownloading(
|
!isBrowserCurrentlyDownloading(
|
||||||
selectedBrowser,
|
selectedBrowser,
|
||||||
) &&
|
) &&
|
||||||
!isBrowserVersionAvailable(selectedBrowser) &&
|
!getCreatableVersion(selectedBrowser) &&
|
||||||
getBestAvailableVersion(selectedBrowser) && (
|
getBestAvailableVersion(selectedBrowser) && (
|
||||||
<div className="flex gap-3 items-center">
|
<div className="flex gap-3 items-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -1494,16 +1350,15 @@ export function CreateProfileDialog({
|
|||||||
!isBrowserCurrentlyDownloading(
|
!isBrowserCurrentlyDownloading(
|
||||||
selectedBrowser,
|
selectedBrowser,
|
||||||
) &&
|
) &&
|
||||||
isBrowserVersionAvailable(selectedBrowser) && (
|
getCreatableVersion(selectedBrowser) && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
✓{" "}
|
✓{" "}
|
||||||
{t(
|
{t(
|
||||||
"createProfile.version.latestAvailable",
|
"createProfile.version.latestAvailable",
|
||||||
{
|
{
|
||||||
version:
|
version:
|
||||||
getBestAvailableVersion(
|
getCreatableVersion(selectedBrowser)
|
||||||
selectedBrowser,
|
?.version,
|
||||||
)?.version,
|
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1701,7 +1556,7 @@ export function CreateProfileDialog({
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<DialogFooter className="flex-shrink-0 pt-4 border-t">
|
<DialogFooter className="shrink-0 pt-4 border-t">
|
||||||
{currentStep === "browser-config" ? (
|
{currentStep === "browser-config" ? (
|
||||||
<>
|
<>
|
||||||
<RippleButton variant="outline" onClick={handleBack}>
|
<RippleButton variant="outline" onClick={handleBack}>
|
||||||
|
|||||||
@@ -83,12 +83,7 @@ interface ErrorToastProps extends BaseToastProps {
|
|||||||
|
|
||||||
interface DownloadToastProps extends BaseToastProps {
|
interface DownloadToastProps extends BaseToastProps {
|
||||||
type: "download";
|
type: "download";
|
||||||
stage?:
|
stage?: "downloading" | "extracting" | "verifying" | "completed";
|
||||||
| "downloading"
|
|
||||||
| "extracting"
|
|
||||||
| "verifying"
|
|
||||||
| "completed"
|
|
||||||
| "downloading (twilight rolling release)";
|
|
||||||
progress?: {
|
progress?: {
|
||||||
percentage: number;
|
percentage: number;
|
||||||
speed?: string;
|
speed?: string;
|
||||||
@@ -111,12 +106,6 @@ interface FetchingToastProps extends BaseToastProps {
|
|||||||
browserName?: string;
|
browserName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TwilightUpdateToastProps extends BaseToastProps {
|
|
||||||
type: "twilight-update";
|
|
||||||
browserName?: string;
|
|
||||||
hasUpdate?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SyncProgressToastProps extends BaseToastProps {
|
interface SyncProgressToastProps extends BaseToastProps {
|
||||||
type: "sync-progress";
|
type: "sync-progress";
|
||||||
progress?: {
|
progress?: {
|
||||||
@@ -138,7 +127,6 @@ type ToastProps =
|
|||||||
| DownloadToastProps
|
| DownloadToastProps
|
||||||
| VersionUpdateToastProps
|
| VersionUpdateToastProps
|
||||||
| FetchingToastProps
|
| FetchingToastProps
|
||||||
| TwilightUpdateToastProps
|
|
||||||
| SyncProgressToastProps;
|
| SyncProgressToastProps;
|
||||||
|
|
||||||
function formatBytesCompact(bytes: number): string {
|
function formatBytesCompact(bytes: number): string {
|
||||||
@@ -174,42 +162,34 @@ function formatEtaCompact(seconds: number): string {
|
|||||||
function getToastIcon(type: ToastProps["type"], stage?: string) {
|
function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "success":
|
case "success":
|
||||||
return <LuCheckCheck className="flex-shrink-0 size-4 text-foreground" />;
|
return <LuCheckCheck className="shrink-0 size-4 text-foreground" />;
|
||||||
case "error":
|
case "error":
|
||||||
return (
|
return <LuTriangleAlert className="shrink-0 size-4 text-foreground" />;
|
||||||
<LuTriangleAlert className="flex-shrink-0 size-4 text-foreground" />
|
|
||||||
);
|
|
||||||
case "download":
|
case "download":
|
||||||
if (stage === "completed") {
|
if (stage === "completed") {
|
||||||
return (
|
return <LuCheckCheck className="shrink-0 size-4 text-foreground" />;
|
||||||
<LuCheckCheck className="flex-shrink-0 size-4 text-foreground" />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return <LuDownload className="flex-shrink-0 size-4 text-foreground" />;
|
return <LuDownload className="shrink-0 size-4 text-foreground" />;
|
||||||
|
|
||||||
case "version-update":
|
case "version-update":
|
||||||
return (
|
return (
|
||||||
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
|
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
||||||
);
|
);
|
||||||
case "fetching":
|
case "fetching":
|
||||||
return (
|
return (
|
||||||
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
|
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
||||||
);
|
|
||||||
case "twilight-update":
|
|
||||||
return (
|
|
||||||
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
|
|
||||||
);
|
);
|
||||||
case "sync-progress":
|
case "sync-progress":
|
||||||
return (
|
return (
|
||||||
<LuRefreshCw className="flex-shrink-0 size-4 animate-spin text-foreground" />
|
<LuRefreshCw className="shrink-0 size-4 animate-spin text-foreground" />
|
||||||
);
|
);
|
||||||
case "loading":
|
case "loading":
|
||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
|
<div className="shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
|
<div className="shrink-0 size-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,7 +212,7 @@ export function UnifiedToast(props: ToastProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||||
aria-label={t("common.buttons.cancel")}
|
aria-label={t("common.buttons.cancel")}
|
||||||
>
|
>
|
||||||
<LuX className="size-3" />
|
<LuX className="size-3" />
|
||||||
@@ -250,7 +230,8 @@ export function UnifiedToast(props: ToastProps) {
|
|||||||
<p className="flex-1 min-w-0 text-xs text-muted-foreground">
|
<p className="flex-1 min-w-0 text-xs text-muted-foreground">
|
||||||
{progress.percentage.toFixed(1)}%
|
{progress.percentage.toFixed(1)}%
|
||||||
{progress.speed && ` • ${progress.speed} MB/s`}
|
{progress.speed && ` • ${progress.speed} MB/s`}
|
||||||
{progress.eta && ` • ${progress.eta} remaining`}
|
{progress.eta &&
|
||||||
|
` • ${t("toasts.progress.remaining", { time: progress.eta })}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-muted rounded-full h-1.5">
|
<div className="w-full bg-muted rounded-full h-1.5">
|
||||||
@@ -268,9 +249,10 @@ export function UnifiedToast(props: ToastProps) {
|
|||||||
"current_browser" in progress && (
|
"current_browser" in progress && (
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{progress.current_browser && (
|
{progress.current_browser &&
|
||||||
<>Looking for updates for {progress.current_browser}</>
|
t("versionUpdater.toast.lookingForUpdates", {
|
||||||
)}
|
browser: progress.current_browser,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<div className="flex-1 bg-muted rounded-full h-1.5 min-w-0">
|
<div className="flex-1 bg-muted rounded-full h-1.5 min-w-0">
|
||||||
@@ -297,7 +279,10 @@ export function UnifiedToast(props: ToastProps) {
|
|||||||
{progress.phase === "uploading"
|
{progress.phase === "uploading"
|
||||||
? t("appUpdate.toast.uploading")
|
? t("appUpdate.toast.uploading")
|
||||||
: t("appUpdate.toast.downloading")}{" "}
|
: t("appUpdate.toast.downloading")}{" "}
|
||||||
{progress.completed_files}/{progress.total_files} files
|
{t("toasts.progress.filesProgress", {
|
||||||
|
completed: progress.completed_files,
|
||||||
|
total: progress.total_files,
|
||||||
|
})}
|
||||||
{" \u2022 "}
|
{" \u2022 "}
|
||||||
{formatBytesCompact(progress.completed_bytes)} /{" "}
|
{formatBytesCompact(progress.completed_bytes)} /{" "}
|
||||||
{formatBytesCompact(progress.total_bytes)}
|
{formatBytesCompact(progress.total_bytes)}
|
||||||
@@ -308,37 +293,21 @@ export function UnifiedToast(props: ToastProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{progress.eta_seconds > 0 &&
|
{progress.eta_seconds > 0 &&
|
||||||
progress.completed_files < progress.total_files && (
|
progress.completed_files < progress.total_files &&
|
||||||
<>
|
` \u2022 ${t("toasts.progress.remaining", {
|
||||||
{" \u2022 ~"}
|
time: `~${formatEtaCompact(progress.eta_seconds)}`,
|
||||||
{formatEtaCompact(progress.eta_seconds)} remaining
|
})}`}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
{progress.failed_count > 0 && (
|
{progress.failed_count > 0 && (
|
||||||
<p className="text-xs text-destructive mt-0.5">
|
<p className="text-xs text-destructive mt-0.5">
|
||||||
{progress.failed_count} file(s) failed
|
{t("toasts.progress.filesFailed", {
|
||||||
|
count: progress.failed_count,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
||||||
{description && (
|
{description && (
|
||||||
<p className="mt-1 text-xs leading-tight text-muted-foreground">
|
<p className="mt-1 text-xs leading-tight text-muted-foreground">
|
||||||
@@ -359,11 +328,6 @@ export function UnifiedToast(props: ToastProps) {
|
|||||||
{t("browserDownload.toast.verifying")}
|
{t("browserDownload.toast.verifying")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{stage === "downloading (twilight rolling release)" && (
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{t("browserDownload.toast.downloadingRolling")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{action &&
|
{action &&
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { LuExternalLink } from "react-icons/lu";
|
import { LuExternalLink } from "react-icons/lu";
|
||||||
@@ -45,7 +46,7 @@ export function DeviceCodeVerifyDialog({
|
|||||||
const handleOpenLogin = async () => {
|
const handleOpenLogin = async () => {
|
||||||
setIsOpeningLogin(true);
|
setIsOpeningLogin(true);
|
||||||
try {
|
try {
|
||||||
await invoke("handle_url_open", { url: DEVICE_LINK_URL });
|
await openUrl(DEVICE_LINK_URL);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to open login link:", error);
|
console.error("Failed to open login link:", error);
|
||||||
showErrorToast(String(error));
|
showErrorToast(String(error));
|
||||||
|
|||||||
@@ -1129,10 +1129,10 @@ export function ExtensionManagementDialog({
|
|||||||
{limitedMode && (
|
{limitedMode && (
|
||||||
<>
|
<>
|
||||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
||||||
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
|
<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-gradient-to-l 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-gradient-to-b 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-gradient-to-t 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 flex items-center justify-center z-[3]">
|
||||||
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
||||||
<ProBadge />
|
<ProBadge />
|
||||||
|
|||||||
@@ -148,10 +148,10 @@ export function GroupBadges({
|
|||||||
return (
|
return (
|
||||||
<div className="relative mb-4">
|
<div className="relative mb-4">
|
||||||
{showLeftFade && (
|
{showLeftFade && (
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-background to-transparent pointer-events-none z-10" />
|
<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 && (
|
{showRightFade && (
|
||||||
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none z-10" />
|
<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
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
@@ -165,7 +165,7 @@ export function GroupBadges({
|
|||||||
<Badge
|
<Badge
|
||||||
key={group.id}
|
key={group.id}
|
||||||
variant={selectedGroupId === group.id ? "default" : "secondary"}
|
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 flex-shrink-0"
|
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) => {
|
onClick={(e) => {
|
||||||
if (hasMovedRef.current || clickBlockedRef.current) {
|
if (hasMovedRef.current || clickBlockedRef.current) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -321,6 +321,7 @@ const HomeHeader = ({
|
|||||||
<span className="shrink-0">
|
<span className="shrink-0">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
data-onborda="create-profile"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onCreateProfileDialogOpen(true);
|
onCreateProfileDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { FaFolder } from "react-icons/fa";
|
import { FaFolder } from "react-icons/fa";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { LoadingButton } from "@/components/loading-button";
|
import { LoadingButton } from "@/components/loading-button";
|
||||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import {
|
import {
|
||||||
AnimatedTabs,
|
AnimatedTabs,
|
||||||
@@ -34,9 +33,10 @@ import {
|
|||||||
import { WayfernConfigForm } from "@/components/wayfern-config-form";
|
import { WayfernConfigForm } from "@/components/wayfern-config-form";
|
||||||
import { useBrowserSupport } from "@/hooks/use-browser-support";
|
import { useBrowserSupport } from "@/hooks/use-browser-support";
|
||||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||||
|
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||||
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CamoufoxConfig, DetectedProfile, WayfernConfig } from "@/types";
|
import type { DetectedProfile, WayfernConfig } from "@/types";
|
||||||
import { RippleButton } from "./ui/ripple";
|
import { RippleButton } from "./ui/ripple";
|
||||||
|
|
||||||
const getMappedBrowser = (browser: string): "camoufox" | "wayfern" => {
|
const getMappedBrowser = (browser: string): "camoufox" | "wayfern" => {
|
||||||
@@ -70,7 +70,6 @@ export function ImportProfileDialog({
|
|||||||
const [currentStep, setCurrentStep] = useState<"select" | "configure">(
|
const [currentStep, setCurrentStep] = useState<"select" | "configure">(
|
||||||
"select",
|
"select",
|
||||||
);
|
);
|
||||||
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({});
|
|
||||||
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>({});
|
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>({});
|
||||||
const [selectedProxyId, setSelectedProxyId] = useState<string | undefined>();
|
const [selectedProxyId, setSelectedProxyId] = useState<string | undefined>();
|
||||||
|
|
||||||
@@ -91,7 +90,11 @@ export function ImportProfileDialog({
|
|||||||
useBrowserSupport();
|
useBrowserSupport();
|
||||||
const { storedProxies } = useProxyEvents();
|
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 () => {
|
const loadDetectedProfiles = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -176,7 +179,7 @@ export function ImportProfileDialog({
|
|||||||
|
|
||||||
const mappedBrowser =
|
const mappedBrowser =
|
||||||
importMode === "auto-detect" && selectedProfile
|
importMode === "auto-detect" && selectedProfile
|
||||||
? (selectedProfile.mapped_browser as "camoufox" | "wayfern")
|
? getMappedBrowser(selectedProfile.mapped_browser)
|
||||||
: getMappedBrowser(browserType);
|
: getMappedBrowser(browserType);
|
||||||
|
|
||||||
setIsImporting(true);
|
setIsImporting(true);
|
||||||
@@ -186,7 +189,8 @@ export function ImportProfileDialog({
|
|||||||
browserType,
|
browserType,
|
||||||
newProfileName,
|
newProfileName,
|
||||||
proxyId: selectedProxyId ?? null,
|
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,
|
wayfernConfig: mappedBrowser === "wayfern" ? wayfernConfig : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,7 +203,10 @@ export function ImportProfileDialog({
|
|||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : String(error);
|
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);
|
const browserDisplayName = getBrowserDisplayName(browserType);
|
||||||
toast.error(
|
toast.error(
|
||||||
t("importProfile.notInstalled", { browser: browserDisplayName }),
|
t("importProfile.notInstalled", { browser: browserDisplayName }),
|
||||||
@@ -222,7 +229,6 @@ export function ImportProfileDialog({
|
|||||||
manualProfilePath,
|
manualProfilePath,
|
||||||
manualProfileName,
|
manualProfileName,
|
||||||
selectedProxyId,
|
selectedProxyId,
|
||||||
camoufoxConfig,
|
|
||||||
wayfernConfig,
|
wayfernConfig,
|
||||||
onClose,
|
onClose,
|
||||||
selectedProfile,
|
selectedProfile,
|
||||||
@@ -231,7 +237,6 @@ export function ImportProfileDialog({
|
|||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setCurrentStep("select");
|
setCurrentStep("select");
|
||||||
setCamoufoxConfig({});
|
|
||||||
setWayfernConfig({});
|
setWayfernConfig({});
|
||||||
setSelectedProxyId(undefined);
|
setSelectedProxyId(undefined);
|
||||||
setSelectedDetectedProfile(null);
|
setSelectedDetectedProfile(null);
|
||||||
@@ -262,10 +267,10 @@ export function ImportProfileDialog({
|
|||||||
|
|
||||||
const currentMappedBrowser = useMemo(() => {
|
const currentMappedBrowser = useMemo(() => {
|
||||||
if (importMode === "auto-detect" && selectedProfile) {
|
if (importMode === "auto-detect" && selectedProfile) {
|
||||||
return selectedProfile.mapped_browser as "camoufox" | "wayfern";
|
return getMappedBrowser(selectedProfile.mapped_browser);
|
||||||
}
|
}
|
||||||
if (importMode === "manual" && manualBrowserType) {
|
if (importMode === "manual" && manualBrowserType) {
|
||||||
return manualBrowserType as "camoufox" | "wayfern";
|
return getMappedBrowser(manualBrowserType);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [importMode, selectedProfile, manualBrowserType]);
|
}, [importMode, selectedProfile, manualBrowserType]);
|
||||||
@@ -303,7 +308,7 @@ export function ImportProfileDialog({
|
|||||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
|
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
|
||||||
{!subPage && (
|
{!subPage && (
|
||||||
<DialogHeader className="flex-shrink-0">
|
<DialogHeader className="shrink-0">
|
||||||
<DialogTitle>{t("importProfile.title")}</DialogTitle>
|
<DialogTitle>{t("importProfile.title")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
)}
|
)}
|
||||||
@@ -577,34 +582,24 @@ export function ImportProfileDialog({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentMappedBrowser === "camoufox" ? (
|
{/* Only Wayfern profiles are importable now (Camoufox/Firefox
|
||||||
<SharedCamoufoxConfigForm
|
import is deprecated and blocked). */}
|
||||||
config={camoufoxConfig}
|
<WayfernConfigForm
|
||||||
onConfigChange={(key, value) => {
|
config={wayfernConfig}
|
||||||
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
|
onConfigChange={(key, value) => {
|
||||||
}}
|
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
||||||
isCreating={true}
|
}}
|
||||||
crossOsUnlocked={crossOsUnlocked}
|
isCreating={true}
|
||||||
limitedMode={!crossOsUnlocked}
|
crossOsUnlocked={crossOsUnlocked}
|
||||||
/>
|
limitedMode={!crossOsUnlocked}
|
||||||
) : (
|
/>
|
||||||
<WayfernConfigForm
|
|
||||||
config={wayfernConfig}
|
|
||||||
onConfigChange={(key, value) => {
|
|
||||||
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
|
||||||
}}
|
|
||||||
isCreating={true}
|
|
||||||
crossOsUnlocked={crossOsUnlocked}
|
|
||||||
limitedMode={!crossOsUnlocked}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-shrink-0 flex gap-2 items-center justify-end",
|
"shrink-0 flex gap-2 items-center justify-end",
|
||||||
subPage ? "pt-2 border-t border-border" : undefined,
|
subPage ? "pt-2 border-t border-border" : undefined,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { LoadingButton } from "@/components/loading-button";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
|
||||||
|
|
||||||
interface LaunchOnLoginDialogProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LaunchOnLoginDialog({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
}: LaunchOnLoginDialogProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isEnabling, setIsEnabling] = useState(false);
|
|
||||||
const [isDeclining, setIsDeclining] = useState(false);
|
|
||||||
|
|
||||||
const handleEnable = useCallback(async () => {
|
|
||||||
setIsEnabling(true);
|
|
||||||
try {
|
|
||||||
await invoke("enable_launch_on_login");
|
|
||||||
showSuccessToast(t("launchOnLogin.enableSuccess"));
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to enable launch on login:", error);
|
|
||||||
showErrorToast(t("launchOnLogin.enableFailed"), {
|
|
||||||
description:
|
|
||||||
error instanceof Error ? error.message : t("launchOnLogin.tryAgain"),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsEnabling(false);
|
|
||||||
}
|
|
||||||
}, [onClose, t]);
|
|
||||||
|
|
||||||
const handleDecline = useCallback(async () => {
|
|
||||||
setIsDeclining(true);
|
|
||||||
try {
|
|
||||||
await invoke("decline_launch_on_login");
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to decline launch on login:", error);
|
|
||||||
showErrorToast(t("launchOnLogin.declineFailed"), {
|
|
||||||
description:
|
|
||||||
error instanceof Error ? error.message : t("launchOnLogin.tryAgain"),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsDeclining(false);
|
|
||||||
}
|
|
||||||
}, [onClose, t]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen}>
|
|
||||||
<DialogContent
|
|
||||||
className="sm:max-w-sm"
|
|
||||||
onEscapeKeyDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
onPointerDownOutside={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
onInteractOutside={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t("launchOnLogin.title")}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("launchOnLogin.description")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<DialogFooter className="flex-row justify-between sm:justify-between">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleDecline}
|
|
||||||
disabled={isEnabling || isDeclining}
|
|
||||||
>
|
|
||||||
{isDeclining
|
|
||||||
? t("launchOnLogin.declining")
|
|
||||||
: t("launchOnLogin.declineButton")}
|
|
||||||
</Button>
|
|
||||||
<LoadingButton
|
|
||||||
onClick={handleEnable}
|
|
||||||
isLoading={isEnabling}
|
|
||||||
disabled={isDeclining}
|
|
||||||
>
|
|
||||||
{t("launchOnLogin.enableButton")}
|
|
||||||
</LoadingButton>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { CardComponentProps } from "onborda";
|
||||||
|
import { useOnborda } from "onborda";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ONBOARDING_TOUR_FINISHED_EVENT } from "@/lib/onboarding-signal";
|
||||||
|
|
||||||
|
// Custom Onborda card, themed with the app's CSS variables. Finishing the last
|
||||||
|
// step emits ONBOARDING_TOUR_FINISHED_EVENT so the page can show the celebratory
|
||||||
|
// thank-you dialog (skipping early does not emit it).
|
||||||
|
export function OnboardingCard({
|
||||||
|
step,
|
||||||
|
currentStep,
|
||||||
|
totalSteps,
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
arrow,
|
||||||
|
}: CardComponentProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { closeOnborda } = useOnborda();
|
||||||
|
|
||||||
|
const isFirst = currentStep === 0;
|
||||||
|
const isLast = currentStep === totalSteps - 1;
|
||||||
|
// This step is completed by clicking the highlighted element (the "New"
|
||||||
|
// button), not by a "Next" button — advancing manually would jump to a step
|
||||||
|
// whose target doesn't exist yet and block the button. So hide "Next" here.
|
||||||
|
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">
|
||||||
|
{currentStep + 1}/{totalSteps}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{step.content}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 items-center justify-between mt-4">
|
||||||
|
{isLast ? (
|
||||||
|
<span />
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
closeOnborda();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("onboarding.buttons.skip")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
{!isFirst && !isLast && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs h-7 px-2.5"
|
||||||
|
onClick={() => {
|
||||||
|
prevStep();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("onboarding.buttons.back")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isLast ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="text-xs h-7 px-3"
|
||||||
|
onClick={() => {
|
||||||
|
closeOnborda();
|
||||||
|
window.dispatchEvent(new Event(ONBOARDING_TOUR_FINISHED_EVENT));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("onboarding.buttons.finish")}
|
||||||
|
</Button>
|
||||||
|
) : requiresAction ? null : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="text-xs h-7 px-3"
|
||||||
|
onClick={() => {
|
||||||
|
nextStep();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("onboarding.buttons.next")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-popover">{arrow}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Onborda, type OnbordaProps, OnbordaProvider } from "onborda";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { OnboardingCard } from "@/components/onboarding-card";
|
||||||
|
|
||||||
|
// Name of the first-run product tour. Referenced by the trigger logic in
|
||||||
|
// `src/app/page.tsx` via `startOnborda(ONBOARDING_TOUR)`.
|
||||||
|
export const ONBOARDING_TOUR = "donut-onboarding";
|
||||||
|
|
||||||
|
export function OnboardingProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const tours: OnbordaProps["steps"] = [
|
||||||
|
{
|
||||||
|
tour: ONBOARDING_TOUR,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
icon: null,
|
||||||
|
title: t("onboarding.steps.createProfile.title"),
|
||||||
|
content: t("onboarding.steps.createProfile.content"),
|
||||||
|
selector: '[data-onborda="create-profile"]',
|
||||||
|
// The "New" button sits in the top-right corner; "bottom-right"
|
||||||
|
// anchors the card's right edge to it so the card extends left/down
|
||||||
|
// and stays on-screen instead of overflowing the right viewport edge.
|
||||||
|
side: "bottom-right",
|
||||||
|
showControls: true,
|
||||||
|
pointerPadding: 8,
|
||||||
|
pointerRadius: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: null,
|
||||||
|
title: t("onboarding.steps.dnsBlocking.title"),
|
||||||
|
content: t("onboarding.steps.dnsBlocking.content"),
|
||||||
|
selector: '[data-onborda="dns-blocklist"]',
|
||||||
|
// The DNS dropdown sits in the right-hand columns. A centered "bottom"
|
||||||
|
// card runs off the right edge; "bottom-right" anchors the card's right
|
||||||
|
// edge to the dropdown and extends it left/down, keeping it fully
|
||||||
|
// on-screen with its arrow pointing up at the option.
|
||||||
|
side: "bottom-right",
|
||||||
|
showControls: true,
|
||||||
|
pointerPadding: 6,
|
||||||
|
pointerRadius: 8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnbordaProvider>
|
||||||
|
<Onborda
|
||||||
|
steps={tours}
|
||||||
|
cardComponent={OnboardingCard}
|
||||||
|
interact
|
||||||
|
shadowRgb="0,0,0"
|
||||||
|
shadowOpacity="0.6"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Onborda>
|
||||||
|
</OnbordaProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -131,9 +131,9 @@ export function PermissionDialog({
|
|||||||
const getPermissionIcon = (type: PermissionType) => {
|
const getPermissionIcon = (type: PermissionType) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "microphone":
|
case "microphone":
|
||||||
return <BsMic className="size-8" />;
|
return <BsMic className="size-5 shrink-0" />;
|
||||||
case "camera":
|
case "camera":
|
||||||
return <BsCamera className="size-8" />;
|
return <BsCamera className="size-5 shrink-0" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -195,13 +195,11 @@ export function PermissionDialog({
|
|||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader className="text-center">
|
<DialogHeader className="text-center">
|
||||||
<div className="flex justify-center items-center mx-auto mb-4 size-16 bg-primary/15 rounded-full">
|
<DialogTitle className="flex items-center justify-center gap-2 text-xl">
|
||||||
{getPermissionIcon(permissionType)}
|
{getPermissionIcon(permissionType)}
|
||||||
</div>
|
|
||||||
<DialogTitle className="text-xl">
|
|
||||||
{getPermissionTitle(permissionType)}
|
{getPermissionTitle(permissionType)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-base">
|
<DialogDescription className="text-base text-pretty">
|
||||||
{getPermissionDescription(permissionType)}
|
{getPermissionDescription(permissionType)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -441,6 +441,7 @@ function DnsCell({
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-onborda="dns-blocklist"
|
||||||
disabled={isSaving}
|
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 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"
|
||||||
title={
|
title={
|
||||||
@@ -2038,12 +2039,12 @@ export function ProfilesDataTable({
|
|||||||
|
|
||||||
if (isDisabled) {
|
if (isDisabled) {
|
||||||
const tooltipMessage = isRunning
|
const tooltipMessage = isRunning
|
||||||
? "Can't modify running profile"
|
? t("profiles.table.cantModifyRunning")
|
||||||
: isLaunching
|
: isLaunching
|
||||||
? "Can't modify profile while launching"
|
? t("profiles.table.cantModifyLaunching")
|
||||||
: isStopping
|
: isStopping
|
||||||
? "Can't modify profile while stopping"
|
? t("profiles.table.cantModifyStopping")
|
||||||
: "Can't modify profile while browser is updating";
|
: t("profiles.table.cantModifyUpdating");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
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 * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
|
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
|
||||||
@@ -11,11 +13,13 @@ import {
|
|||||||
LuClipboardCheck,
|
LuClipboardCheck,
|
||||||
LuCookie,
|
LuCookie,
|
||||||
LuCopy,
|
LuCopy,
|
||||||
|
LuDownload,
|
||||||
LuFingerprint,
|
LuFingerprint,
|
||||||
LuGlobe,
|
LuGlobe,
|
||||||
LuGroup,
|
LuGroup,
|
||||||
LuKey,
|
LuKey,
|
||||||
LuLink,
|
LuLink,
|
||||||
|
LuLock,
|
||||||
LuLockOpen,
|
LuLockOpen,
|
||||||
LuPlus,
|
LuPlus,
|
||||||
LuPuzzle,
|
LuPuzzle,
|
||||||
@@ -38,6 +42,12 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
@@ -262,9 +272,9 @@ export function ProfileInfoDialog({
|
|||||||
? vpnConfigs.find((v) => v.id === profile.vpn_id)?.name
|
? vpnConfigs.find((v) => v.id === profile.vpn_id)?.name
|
||||||
: null;
|
: null;
|
||||||
const networkLabel = vpnName
|
const networkLabel = vpnName
|
||||||
? `VPN: ${vpnName}`
|
? t("profileInfo.network.vpnLabel", { name: vpnName })
|
||||||
: proxyName
|
: proxyName
|
||||||
? `Proxy: ${proxyName}`
|
? t("profileInfo.network.proxyLabel", { name: proxyName })
|
||||||
: t("profileInfo.values.none");
|
: t("profileInfo.values.none");
|
||||||
|
|
||||||
const syncStatus = syncStatuses[profile.id];
|
const syncStatus = syncStatuses[profile.id];
|
||||||
@@ -298,6 +308,10 @@ export function ProfileInfoDialog({
|
|||||||
// `ProfileDnsBlocklistDialog` for the pattern). The settings tab is purely
|
// `ProfileDnsBlocklistDialog` for the pattern). The settings tab is purely
|
||||||
// a navigation hub.
|
// a navigation hub.
|
||||||
interface ActionItem {
|
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;
|
icon: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -310,6 +324,7 @@ export function ProfileInfoDialog({
|
|||||||
|
|
||||||
const actions: ActionItem[] = [
|
const actions: ActionItem[] = [
|
||||||
{
|
{
|
||||||
|
id: "network",
|
||||||
icon: <LuGlobe className="size-4" />,
|
icon: <LuGlobe className="size-4" />,
|
||||||
label: t("profiles.actions.viewNetwork"),
|
label: t("profiles.actions.viewNetwork"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -318,6 +333,7 @@ export function ProfileInfoDialog({
|
|||||||
disabled: isCrossOs,
|
disabled: isCrossOs,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "sync",
|
||||||
icon: <LuRefreshCw className="size-4" />,
|
icon: <LuRefreshCw className="size-4" />,
|
||||||
label: t("profiles.actions.syncSettings"),
|
label: t("profiles.actions.syncSettings"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -336,12 +352,15 @@ export function ProfileInfoDialog({
|
|||||||
runningBadge: isRunning,
|
runningBadge: isRunning,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "fingerprint",
|
||||||
icon: <LuFingerprint className="size-4" />,
|
icon: <LuFingerprint className="size-4" />,
|
||||||
label: t("profiles.actions.changeFingerprint"),
|
label: t("profiles.actions.changeFingerprint"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
handleAction(() => onConfigureCamoufox?.(profile));
|
handleAction(() => onConfigureCamoufox?.(profile));
|
||||||
},
|
},
|
||||||
disabled: isDisabled,
|
// Viewing and editing fingerprints both require an active paid plan.
|
||||||
|
disabled: isDisabled || !crossOsUnlocked,
|
||||||
|
proBadge: !crossOsUnlocked,
|
||||||
runningBadge: isRunning,
|
runningBadge: isRunning,
|
||||||
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
|
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
|
||||||
},
|
},
|
||||||
@@ -356,6 +375,7 @@ export function ProfileInfoDialog({
|
|||||||
hidden: profile.browser !== "wayfern" || !onLaunchWithSync,
|
hidden: profile.browser !== "wayfern" || !onLaunchWithSync,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "cookiesCopy",
|
||||||
icon: <LuCopy className="size-4" />,
|
icon: <LuCopy className="size-4" />,
|
||||||
label: t("profiles.actions.copyCookiesToProfile"),
|
label: t("profiles.actions.copyCookiesToProfile"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -369,6 +389,7 @@ export function ProfileInfoDialog({
|
|||||||
!onCopyCookiesToProfile,
|
!onCopyCookiesToProfile,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "cookiesManage",
|
||||||
icon: <LuCookie className="size-4" />,
|
icon: <LuCookie className="size-4" />,
|
||||||
label: t("profileInfo.actions.manageCookies"),
|
label: t("profileInfo.actions.manageCookies"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -392,6 +413,7 @@ export function ProfileInfoDialog({
|
|||||||
hidden: profile.ephemeral === true,
|
hidden: profile.ephemeral === true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "extension",
|
||||||
icon: <LuPuzzle className="size-4" />,
|
icon: <LuPuzzle className="size-4" />,
|
||||||
label: t("profileInfo.actions.assignExtensionGroup"),
|
label: t("profileInfo.actions.assignExtensionGroup"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -416,6 +438,7 @@ export function ProfileInfoDialog({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "hook",
|
||||||
icon: <LuLink className="size-4" />,
|
icon: <LuLink className="size-4" />,
|
||||||
label: t("profiles.actions.launchHook"),
|
label: t("profiles.actions.launchHook"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -458,6 +481,7 @@ export function ProfileInfoDialog({
|
|||||||
destructive: true,
|
destructive: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "delete",
|
||||||
icon: <LuTrash2 className="size-4" />,
|
icon: <LuTrash2 className="size-4" />,
|
||||||
label: t("profiles.actions.delete"),
|
label: t("profiles.actions.delete"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -481,6 +505,9 @@ export function ProfileInfoDialog({
|
|||||||
hideClose
|
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="sm:max-w-3xl w-[720px] max-w-[720px] h-[480px] max-h-[480px] flex flex-col p-0 gap-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
|
{/* The dialog renders its own custom header, so the accessible title is
|
||||||
|
visually hidden but present for screen readers (Radix requires it). */}
|
||||||
|
<DialogTitle className="sr-only">{t("profileInfo.title")}</DialogTitle>
|
||||||
<ProfileInfoLayout
|
<ProfileInfoLayout
|
||||||
profile={profile}
|
profile={profile}
|
||||||
ProfileIcon={ProfileIcon}
|
ProfileIcon={ProfileIcon}
|
||||||
@@ -528,6 +555,7 @@ interface ProfileInfoLayoutProps {
|
|||||||
onCloneProfile?: (profile: BrowserProfile) => void;
|
onCloneProfile?: (profile: BrowserProfile) => void;
|
||||||
onKillProfile?: (profile: BrowserProfile) => void;
|
onKillProfile?: (profile: BrowserProfile) => void;
|
||||||
visibleActions: {
|
visibleActions: {
|
||||||
|
id?: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -573,22 +601,23 @@ function ProfileInfoLayout({
|
|||||||
}: ProfileInfoLayoutProps) {
|
}: ProfileInfoLayoutProps) {
|
||||||
const [section, setSection] = React.useState<ProfileSection>("overview");
|
const [section, setSection] = React.useState<ProfileSection>("overview");
|
||||||
|
|
||||||
// Map sidebar items to existing action labels, so clicking a section
|
// Map sidebar items to existing actions by their stable, language-independent
|
||||||
// simply triggers the existing dialog handler.
|
// `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(
|
const findAction = React.useCallback(
|
||||||
(substr: string) =>
|
(id: string) => visibleActions.find((a) => a.id === id),
|
||||||
visibleActions.find((a) => a.label.toLowerCase().includes(substr)),
|
|
||||||
[visibleActions],
|
[visibleActions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteAction = findAction("delete");
|
const deleteAction = findAction("delete");
|
||||||
const fingerprintAction = findAction("fingerprint");
|
const fingerprintAction = findAction("fingerprint");
|
||||||
const cookiesManageAction = findAction("manage cookies");
|
const cookiesManageAction = findAction("cookiesManage");
|
||||||
const cookiesCopyAction = findAction("copy cookies");
|
const cookiesCopyAction = findAction("cookiesCopy");
|
||||||
const cookiesAction = cookiesManageAction ?? cookiesCopyAction;
|
const cookiesAction = cookiesManageAction ?? cookiesCopyAction;
|
||||||
const extensionAction = findAction("extension");
|
const extensionAction = findAction("extension");
|
||||||
const syncAction = findAction("sync");
|
const syncAction = findAction("sync");
|
||||||
const _launchHookAction = findAction("hook") ?? findAction("launch hook");
|
const _launchHookAction = findAction("hook");
|
||||||
const _networkAction = findAction("network");
|
const _networkAction = findAction("network");
|
||||||
// Password actions are no longer routed via the legacy action handlers —
|
// Password actions are no longer routed via the legacy action handlers —
|
||||||
// SecuritySectionInline writes directly to the backend instead.
|
// SecuritySectionInline writes directly to the backend instead.
|
||||||
@@ -888,6 +917,7 @@ function ProfileInfoLayout({
|
|||||||
// proBadge state. Default to false if action missing.
|
// proBadge state. Default to false if action missing.
|
||||||
fingerprintAction && !fingerprintAction.proBadge,
|
fingerprintAction && !fingerprintAction.proBadge,
|
||||||
)}
|
)}
|
||||||
|
onSaved={onClose}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1142,7 +1172,7 @@ function SyncSectionInline({
|
|||||||
syncMode: mode,
|
syncMode: mode,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(translateBackendError(t as never, e));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -1185,7 +1215,9 @@ function SyncSectionInline({
|
|||||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
{t("profileInfo.fields.syncStatus")}
|
{t("profileInfo.fields.syncStatus")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm mt-0.5">{syncStatus.status}</p>
|
<p className="text-sm mt-0.5">
|
||||||
|
{t(`profileInfo.syncStatusValue.${syncStatus.status}`)}
|
||||||
|
</p>
|
||||||
{syncStatus.error && (
|
{syncStatus.error && (
|
||||||
<p className="text-xs text-destructive mt-1">{syncStatus.error}</p>
|
<p className="text-xs text-destructive mt-1">{syncStatus.error}</p>
|
||||||
)}
|
)}
|
||||||
@@ -1239,7 +1271,7 @@ function NetworkSectionInline({
|
|||||||
setProxyId(nextId);
|
setProxyId(nextId);
|
||||||
if (nextId !== null) setVpnId(null);
|
if (nextId !== null) setVpnId(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(translateBackendError(t as never, e));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -1257,7 +1289,7 @@ function NetworkSectionInline({
|
|||||||
setVpnId(nextId);
|
setVpnId(nextId);
|
||||||
if (nextId !== null) setProxyId(null);
|
if (nextId !== null) setProxyId(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(translateBackendError(t as never, e));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -1363,7 +1395,7 @@ function ExtensionsSectionInline({
|
|||||||
);
|
);
|
||||||
if (mounted) setGroups(data);
|
if (mounted) setGroups(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) setError(String(e));
|
if (mounted) setError(translateBackendError(t as never, e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void load();
|
void load();
|
||||||
@@ -1377,7 +1409,7 @@ function ExtensionsSectionInline({
|
|||||||
mounted = false;
|
mounted = false;
|
||||||
unlisten?.();
|
unlisten?.();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
const onChange = async (value: string) => {
|
const onChange = async (value: string) => {
|
||||||
const next = value === "__none__" ? null : value;
|
const next = value === "__none__" ? null : value;
|
||||||
@@ -1390,7 +1422,7 @@ function ExtensionsSectionInline({
|
|||||||
});
|
});
|
||||||
setGroupId(next);
|
setGroupId(next);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(translateBackendError(t as never, e));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -1488,6 +1520,41 @@ function CookiesSectionInline({
|
|||||||
};
|
};
|
||||||
}, [profile.id, isRunning, t]);
|
}, [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 ?? [];
|
const domains = stats?.domains ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1498,6 +1565,41 @@ function CookiesSectionInline({
|
|||||||
{t("profileInfo.sections.cookies")}
|
{t("profileInfo.sections.cookies")}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{onImportCookies && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -1507,7 +1609,7 @@ function CookiesSectionInline({
|
|||||||
onClick={onImportCookies}
|
onClick={onImportCookies}
|
||||||
>
|
>
|
||||||
<LuUpload className="size-3.5" />
|
<LuUpload className="size-3.5" />
|
||||||
{t("cookies.import.title")}
|
{t("common.buttons.import")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onCopyCookies && (
|
{onCopyCookies && (
|
||||||
@@ -1519,7 +1621,7 @@ function CookiesSectionInline({
|
|||||||
onClick={onCopyCookies}
|
onClick={onCopyCookies}
|
||||||
>
|
>
|
||||||
<LuCopy className="size-3.5" />
|
<LuCopy className="size-3.5" />
|
||||||
{t("profiles.actions.copyCookies")}
|
{t("common.buttons.copy")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1586,11 +1688,13 @@ function FingerprintSectionInline({
|
|||||||
profile,
|
profile,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
crossOsUnlocked,
|
crossOsUnlocked,
|
||||||
|
onSaved,
|
||||||
t,
|
t,
|
||||||
}: {
|
}: {
|
||||||
profile: BrowserProfile;
|
profile: BrowserProfile;
|
||||||
isDisabled: boolean;
|
isDisabled: boolean;
|
||||||
crossOsUnlocked: boolean;
|
crossOsUnlocked: boolean;
|
||||||
|
onSaved: () => void;
|
||||||
t: (key: string, options?: Record<string, unknown>) => string;
|
t: (key: string, options?: Record<string, unknown>) => string;
|
||||||
}) {
|
}) {
|
||||||
const [camoufoxConfig, setCamoufoxConfig] = React.useState<CamoufoxConfig>(
|
const [camoufoxConfig, setCamoufoxConfig] = React.useState<CamoufoxConfig>(
|
||||||
@@ -1629,6 +1733,23 @@ function FingerprintSectionInline({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Viewing and editing fingerprints both require an active paid plan
|
||||||
|
// (`crossOsUnlocked` is that paid flag here). Render a locked state instead of
|
||||||
|
// the editor so free users can neither see nor change the fingerprint.
|
||||||
|
if (!crossOsUnlocked) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-lg border p-6 text-center">
|
||||||
|
<LuLock className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<h3 className="text-sm font-medium text-foreground">
|
||||||
|
{t("profileInfo.fingerprint.lockedTitle")}
|
||||||
|
</h3>
|
||||||
|
<p className="max-w-[48ch] text-sm text-pretty text-muted-foreground">
|
||||||
|
{t("profileInfo.fingerprint.lockedDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const onCamoufoxChange = (key: keyof CamoufoxConfig, value: unknown) => {
|
const onCamoufoxChange = (key: keyof CamoufoxConfig, value: unknown) => {
|
||||||
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
|
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
@@ -1655,8 +1776,10 @@ function FingerprintSectionInline({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setSuccess(t("common.buttons.saved"));
|
setSuccess(t("common.buttons.saved"));
|
||||||
|
// Close the dialog once the fingerprint is saved.
|
||||||
|
onSaved();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(translateBackendError(t as never, e));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ export function ProfilePasswordDialog({
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{(mode === "set" || mode === "change") && (
|
{(mode === "set" || mode === "change") && (
|
||||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">
|
<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")}
|
{t("profilePassword.warnings.forgetWarningTitle")}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||||
|
import { getEntitlements } from "@/lib/entitlements";
|
||||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||||
import type { BrowserProfile, SyncMode, SyncSettings } from "@/types";
|
import type { BrowserProfile, SyncMode, SyncSettings } from "@/types";
|
||||||
import { isSyncEnabled } from "@/types";
|
import { isSyncEnabled } from "@/types";
|
||||||
@@ -36,11 +37,7 @@ export function ProfileSyncDialog({
|
|||||||
}: ProfileSyncDialogProps) {
|
}: ProfileSyncDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user: cloudUser } = useCloudAuth();
|
const { user: cloudUser } = useCloudAuth();
|
||||||
const isCloudSyncEligible =
|
const isCloudSyncEligible = getEntitlements(cloudUser).cloudBackup;
|
||||||
cloudUser != null &&
|
|
||||||
cloudUser.plan !== "free" &&
|
|
||||||
(cloudUser.subscriptionStatus === "active" ||
|
|
||||||
cloudUser.planPeriod === "lifetime");
|
|
||||||
// Encryption available to everyone except team members who aren't owners
|
// Encryption available to everyone except team members who aren't owners
|
||||||
const canUseEncryption =
|
const canUseEncryption =
|
||||||
cloudUser == null ||
|
cloudUser == null ||
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager";
|
import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import Color from "color";
|
import Color from "color";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -368,19 +369,36 @@ export function SettingsDialog({
|
|||||||
async (permissionType: PermissionType) => {
|
async (permissionType: PermissionType) => {
|
||||||
setRequestingPermission(permissionType);
|
setRequestingPermission(permissionType);
|
||||||
try {
|
try {
|
||||||
await requestPermission(permissionType);
|
const granted = await requestPermission(permissionType);
|
||||||
showSuccessToast(
|
if (granted) {
|
||||||
t("settings.permissions.accessRequested", {
|
showSuccessToast(
|
||||||
permission: getPermissionDisplayName(permissionType),
|
permissionType === "microphone"
|
||||||
}),
|
? t("permissionDialog.grantedToastMicrophone")
|
||||||
|
: t("permissionDialog.grantedToastCamera"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await openUrl(
|
||||||
|
`x-apple.systempreferences:com.apple.preference.security?${
|
||||||
|
permissionType === "microphone"
|
||||||
|
? "Privacy_Microphone"
|
||||||
|
: "Privacy_Camera"
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
showErrorToast(
|
||||||
|
permissionType === "microphone"
|
||||||
|
? t("permissionDialog.stillNotGrantedMicrophone")
|
||||||
|
: t("permissionDialog.stillNotGrantedCamera"),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to request permission:", error);
|
console.error("Failed to request permission:", error);
|
||||||
|
showErrorToast(t("permissionDialog.requestFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setRequestingPermission(null);
|
setRequestingPermission(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getPermissionDisplayName, requestPermission, t],
|
[requestPermission, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
@@ -465,7 +483,8 @@ export function SettingsDialog({
|
|||||||
| "zh"
|
| "zh"
|
||||||
| "ja"
|
| "ja"
|
||||||
| "ko"
|
| "ko"
|
||||||
| "ru"),
|
| "ru"
|
||||||
|
| "vi"),
|
||||||
);
|
);
|
||||||
setOriginalLanguage(selectedLanguage);
|
setOriginalLanguage(selectedLanguage);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -422,7 +422,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value || undefined,
|
e.target.value || undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="Mozilla/5.0..."
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "Mozilla/5.0...",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -436,7 +438,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value || undefined,
|
e.target.value || undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., MacIntel, Win32"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "MacIntel, Win32",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -452,7 +456,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value || undefined,
|
e.target.value || undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 5.0 (Macintosh)"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "5.0 (Macintosh)",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -487,7 +493,7 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 8"
|
placeholder={t("common.placeholders.example", { value: "8" })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -504,7 +510,7 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 0"
|
placeholder={t("common.placeholders.example", { value: "0" })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -549,7 +555,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value || undefined,
|
e.target.value || undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., en-US"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "en-US",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -573,7 +581,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 1920"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "1920",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -590,7 +600,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 1080"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "1080",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -607,7 +619,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 1920"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "1920",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -624,7 +638,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 1055"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "1055",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -641,7 +657,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 30"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "30",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -658,7 +676,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 30"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "30",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -682,7 +702,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 1512"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "1512",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -699,7 +721,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 886"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "886",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -716,7 +740,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 1512"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "1512",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -733,7 +759,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 886"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "886",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -748,7 +776,7 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 0"
|
placeholder={t("common.placeholders.example", { value: "0" })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -763,7 +791,7 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 0"
|
placeholder={t("common.placeholders.example", { value: "0" })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -786,7 +814,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value ? parseFloat(e.target.value) : undefined,
|
e.target.value ? parseFloat(e.target.value) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 41.0019"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "41.0019",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -802,7 +832,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value ? parseFloat(e.target.value) : undefined,
|
e.target.value ? parseFloat(e.target.value) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 28.9645"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "28.9645",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -817,7 +849,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value || undefined,
|
e.target.value || undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., America/New_York"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "America/New_York",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -840,7 +874,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value || undefined,
|
e.target.value || undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., tr"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "tr",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -854,7 +890,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value || undefined,
|
e.target.value || undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., TR"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "TR",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -868,7 +906,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value || undefined,
|
e.target.value || undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., Latn"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "Latn",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -891,7 +931,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value || undefined,
|
e.target.value || undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., Mesa"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "Mesa",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1053,7 +1095,7 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value ? parseFloat(e.target.value) : undefined,
|
e.target.value ? parseFloat(e.target.value) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 0"
|
placeholder={t("common.placeholders.example", { value: "0" })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1071,7 +1113,7 @@ export function SharedCamoufoxConfigForm({
|
|||||||
e.target.value ? parseFloat(e.target.value) : undefined,
|
e.target.value ? parseFloat(e.target.value) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 0"
|
placeholder={t("common.placeholders.example", { value: "0" })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1097,10 +1139,10 @@ export function SharedCamoufoxConfigForm({
|
|||||||
{limitedMode && (
|
{limitedMode && (
|
||||||
<>
|
<>
|
||||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
||||||
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
|
<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-gradient-to-l 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-gradient-to-b 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-gradient-to-t 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 flex items-center justify-center z-[3]">
|
||||||
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
||||||
<ProBadge />
|
<ProBadge />
|
||||||
@@ -1240,7 +1282,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 1920"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "1920",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1259,7 +1303,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 1080"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "1080",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1278,7 +1324,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 800"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "800",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1297,7 +1345,9 @@ export function SharedCamoufoxConfigForm({
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 600"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "600",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1305,10 +1355,10 @@ export function SharedCamoufoxConfigForm({
|
|||||||
{limitedMode && (
|
{limitedMode && (
|
||||||
<>
|
<>
|
||||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
||||||
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
|
<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-gradient-to-l 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-gradient-to-b 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-gradient-to-t 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 flex items-center justify-center z-[3]">
|
||||||
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
||||||
<ProBadge />
|
<ProBadge />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||||
@@ -206,7 +207,7 @@ export function SyncConfigDialog({
|
|||||||
|
|
||||||
const handleOpenLogin = useCallback(async () => {
|
const handleOpenLogin = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await invoke("handle_url_open", { url: DEVICE_LINK_URL });
|
await openUrl(DEVICE_LINK_URL);
|
||||||
// Hand off the verify step to its own dialog so the user has a
|
// Hand off the verify step to its own dialog so the user has a
|
||||||
// focused place to paste the code, and so it doesn't visually
|
// focused place to paste the code, and so it doesn't visually
|
||||||
// stack with this dialog or any other modal currently on screen.
|
// stack with this dialog or any other modal currently on screen.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user