mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-11 17:27:54 +02:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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`);
|
|
||||||
}
|
|
||||||
@@ -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,7 +13,12 @@ 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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -620,7 +620,7 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
|
||||||
- name: Run opencode
|
- name: Run opencode
|
||||||
uses: anomalyco/opencode/github@d74d166acf40e51146f8547216913a4e787a4bc1 #v1.15.10
|
uses: anomalyco/opencode/github@385cb694419f98103af0e8fc6187ddcbcbb6eecb #v1.15.13
|
||||||
env:
|
env:
|
||||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
|
||||||
- name: Determine release tag
|
- name: Determine release tag
|
||||||
id: tag
|
id: tag
|
||||||
env:
|
env:
|
||||||
@@ -40,182 +43,20 @@ 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: bash scripts/publish-repo.sh "${{ steps.tag.outputs.tag }}"
|
||||||
run: |
|
|
||||||
mkdir -p /tmp/packages
|
|
||||||
gh release download "$TAG" \
|
|
||||||
--repo "${{ github.repository }}" \
|
|
||||||
--pattern "*.deb" \
|
|
||||||
--dir /tmp/packages
|
|
||||||
gh release download "$TAG" \
|
|
||||||
--repo "${{ github.repository }}" \
|
|
||||||
--pattern "*.rpm" \
|
|
||||||
--dir /tmp/packages
|
|
||||||
echo "Downloaded packages:"
|
|
||||||
ls -lh /tmp/packages/
|
|
||||||
|
|
||||||
- name: Build DEB repository
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: auto
|
|
||||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
|
||||||
run: |
|
|
||||||
DEB_DIR="/tmp/repo/deb"
|
|
||||||
mkdir -p "$DEB_DIR/pool/main"
|
|
||||||
mkdir -p "$DEB_DIR/dists/stable/main/binary-amd64"
|
|
||||||
mkdir -p "$DEB_DIR/dists/stable/main/binary-arm64"
|
|
||||||
|
|
||||||
# Sync existing pool from R2 (incremental)
|
|
||||||
aws s3 sync "s3://${R2_BUCKET}/deb/pool" "$DEB_DIR/pool" \
|
|
||||||
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Copy new .deb files into pool
|
|
||||||
cp /tmp/packages/*.deb "$DEB_DIR/pool/main/" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Generate Packages and Packages.gz for each arch
|
|
||||||
for arch in amd64 arm64; do
|
|
||||||
BINARY_DIR="$DEB_DIR/dists/stable/main/binary-${arch}"
|
|
||||||
(cd "$DEB_DIR" && dpkg-scanpackages --arch "$arch" pool/main) \
|
|
||||||
> "$BINARY_DIR/Packages"
|
|
||||||
gzip -9c "$BINARY_DIR/Packages" > "$BINARY_DIR/Packages.gz"
|
|
||||||
echo " $arch: $(grep -c '^Package:' "$BINARY_DIR/Packages" 2>/dev/null || echo 0) package(s)"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Generate Release file
|
|
||||||
{
|
|
||||||
echo "Origin: Donut Browser"
|
|
||||||
echo "Label: Donut Browser"
|
|
||||||
echo "Suite: stable"
|
|
||||||
echo "Codename: stable"
|
|
||||||
echo "Architectures: amd64 arm64"
|
|
||||||
echo "Components: main"
|
|
||||||
echo "Date: $(date -u '+%a, %d %b %Y %H:%M:%S UTC')"
|
|
||||||
echo "MD5Sum:"
|
|
||||||
for arch in amd64 arm64; do
|
|
||||||
for file in "main/binary-${arch}/Packages" "main/binary-${arch}/Packages.gz"; do
|
|
||||||
filepath="$DEB_DIR/dists/stable/$file"
|
|
||||||
if [[ -f "$filepath" ]]; then
|
|
||||||
size=$(wc -c < "$filepath")
|
|
||||||
md5=$(md5sum "$filepath" | awk '{print $1}')
|
|
||||||
printf " %s %8d %s\n" "$md5" "$size" "$file"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
done
|
|
||||||
echo "SHA256:"
|
|
||||||
for arch in amd64 arm64; do
|
|
||||||
for file in "main/binary-${arch}/Packages" "main/binary-${arch}/Packages.gz"; do
|
|
||||||
filepath="$DEB_DIR/dists/stable/$file"
|
|
||||||
if [[ -f "$filepath" ]]; then
|
|
||||||
size=$(wc -c < "$filepath")
|
|
||||||
sha256=$(sha256sum "$filepath" | awk '{print $1}')
|
|
||||||
printf " %s %8d %s\n" "$sha256" "$size" "$file"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
done
|
|
||||||
} > "$DEB_DIR/dists/stable/Release"
|
|
||||||
|
|
||||||
echo "DEB Release file created."
|
|
||||||
|
|
||||||
- name: Build RPM repository
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: auto
|
|
||||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
|
||||||
run: |
|
|
||||||
RPM_DIR="/tmp/repo/rpm"
|
|
||||||
mkdir -p "$RPM_DIR/x86_64"
|
|
||||||
mkdir -p "$RPM_DIR/aarch64"
|
|
||||||
|
|
||||||
# Sync existing RPMs from R2 (incremental)
|
|
||||||
aws s3 sync "s3://${R2_BUCKET}/rpm/x86_64" "$RPM_DIR/x86_64" \
|
|
||||||
--endpoint-url "$R2_ENDPOINT" --exclude "repodata/*" 2>/dev/null || true
|
|
||||||
aws s3 sync "s3://${R2_BUCKET}/rpm/aarch64" "$RPM_DIR/aarch64" \
|
|
||||||
--endpoint-url "$R2_ENDPOINT" --exclude "repodata/*" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Copy new .rpm files into arch directories
|
|
||||||
for rpm in /tmp/packages/*.rpm; do
|
|
||||||
[[ -f "$rpm" ]] || continue
|
|
||||||
filename=$(basename "$rpm")
|
|
||||||
if [[ "$filename" == *x86_64* ]]; then
|
|
||||||
cp "$rpm" "$RPM_DIR/x86_64/"
|
|
||||||
elif [[ "$filename" == *aarch64* ]]; then
|
|
||||||
cp "$rpm" "$RPM_DIR/aarch64/"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Generate repodata
|
|
||||||
createrepo_c --update "$RPM_DIR"
|
|
||||||
echo "RPM repodata created."
|
|
||||||
|
|
||||||
- name: Upload to R2
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: auto
|
|
||||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
|
||||||
run: |
|
|
||||||
echo "Uploading DEB repository..."
|
|
||||||
aws s3 sync /tmp/repo/deb/dists "s3://${R2_BUCKET}/deb/dists" \
|
|
||||||
--endpoint-url "$R2_ENDPOINT" --delete
|
|
||||||
aws s3 sync /tmp/repo/deb/pool "s3://${R2_BUCKET}/deb/pool" \
|
|
||||||
--endpoint-url "$R2_ENDPOINT"
|
|
||||||
|
|
||||||
echo "Uploading RPM repository..."
|
|
||||||
aws s3 sync /tmp/repo/rpm "s3://${R2_BUCKET}/rpm" \
|
|
||||||
--endpoint-url "$R2_ENDPOINT"
|
|
||||||
|
|
||||||
- name: Verify upload
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: auto
|
|
||||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
|
||||||
TAG: ${{ steps.tag.outputs.tag }}
|
|
||||||
run: |
|
|
||||||
echo "Published repos for $TAG"
|
|
||||||
echo ""
|
|
||||||
echo "DEB dists/stable/:"
|
|
||||||
aws s3 ls "s3://${R2_BUCKET}/deb/dists/stable/" \
|
|
||||||
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty)"
|
|
||||||
echo "DEB pool/main/:"
|
|
||||||
aws s3 ls "s3://${R2_BUCKET}/deb/pool/main/" \
|
|
||||||
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty)"
|
|
||||||
echo "RPM repodata/:"
|
|
||||||
aws s3 ls "s3://${R2_BUCKET}/rpm/repodata/" \
|
|
||||||
--endpoint-url "$R2_ENDPOINT" 2>/dev/null || echo " (empty)"
|
|
||||||
|
|||||||
@@ -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: |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/"
|
||||||
|
|||||||
@@ -23,4 +23,4 @@ jobs:
|
|||||||
- name: Checkout Actions Repository
|
- name: Checkout Actions Repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
- name: Spell Check Repo
|
- name: Spell Check Repo
|
||||||
uses: crate-ci/typos@aca895bf05aec0cb7dffa6f94495e923224d9f17 #v1.46.2
|
uses: crate-ci/typos@f8a58b6b53f2279f71eb605f03a4ae4d10608f45 #v1.47.0
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -56,6 +56,16 @@ 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 `Camoufox`, `Wayfern`, `Starting local proxy`, `Configured local proxy` to find a launch chain. Dev builds write to `DonutBrowserDev.log` instead.
|
||||||
|
- **donut-proxy worker** — `$TMPDIR/donut-proxy-<config_id>.log`. One file per proxy worker process (each profile launch spawns a fresh one). Map a worker to its launch via the `Cleanup: browser PID X is dead, stopping proxy worker <id>` lines in DonutBrowser.log, or by mtime. CONNECT requests, upstream accept/reject (status lines like `HTTP/1.1 402 user reached limit`), and tunnel errors are at INFO/WARN — anything finer is at TRACE and requires `RUST_LOG=donut_proxy=trace`. The `Upstream CONNECT response coalesced N byte(s) of payload — these would be dropped without forwarding` warning marks a real bug in `handle_connect_from_buffer` if it ever fires.
|
||||||
|
- **Camoufox stderr** — `$TMPDIR/camoufox-stderr-<profile_id>.log`, written by `camoufox_manager::launch_camoufox`. Captures NSS / GPU Helper / juggler errors. Firefox does **not** print TLS/network errors here by default — set `MOZ_LOG=nsHttp:5,signaling:5` on the env if you need that. The `RustSearch.sys.mjs missing field 'recordType'` lines are noise from our `search.json.mozlz4` schema being slightly off for FF150+; not a network problem.
|
||||||
|
|
||||||
|
Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropriate location (see `app_dirs::app_name()`), but the `$TMPDIR` worker logs are always under the system temp dir.
|
||||||
|
|
||||||
## Code Quality
|
## Code Quality
|
||||||
|
|
||||||
- Don't leave comments that don't add value
|
- Don't leave comments that don't add value
|
||||||
@@ -66,12 +76,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 +95,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 +148,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 +158,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 +216,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.
|
||||||
|
|||||||
@@ -1,6 +1,42 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
|
||||||
|
## 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)
|
## v0.24.3 (2026-05-25)
|
||||||
|
|
||||||
### 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
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
| | Apple Silicon | Intel |
|
| | Apple Silicon | Intel |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut_0.24.3_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut_0.24.3_x64.dmg) |
|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_x64.dmg) |
|
||||||
|
|
||||||
Or install via Homebrew:
|
Or install via Homebrew:
|
||||||
|
|
||||||
@@ -56,15 +56,15 @@ brew install --cask donut
|
|||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut_0.24.3_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut_0.24.3_x64-portable.zip)
|
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_x64-portable.zip)
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
| Format | x86_64 | ARM64 |
|
| Format | x86_64 | ARM64 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut_0.24.3_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut_0.24.3_arm64.deb) |
|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_arm64.deb) |
|
||||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut-0.24.3-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut-0.24.3-1.aarch64.rpm) |
|
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut-0.25.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut-0.25.1-1.aarch64.rpm) |
|
||||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut_0.24.3_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.3/Donut_0.24.3_aarch64.AppImage) |
|
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_aarch64.AppImage) |
|
||||||
<!-- install-links-end -->
|
<!-- install-links-end -->
|
||||||
|
|
||||||
Or install via package manager:
|
Or install via package manager:
|
||||||
@@ -135,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"/>
|
||||||
@@ -142,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"/>
|
||||||
@@ -149,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]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -256,6 +256,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 (
|
||||||
@@ -289,6 +293,9 @@ export class SyncService implements OnModuleInit {
|
|||||||
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: dto.metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
|
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
|
||||||
|
|||||||
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.3";
|
releaseVersion = "0.25.1";
|
||||||
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.3/Donut_0.24.3_amd64.AppImage";
|
url = "https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_amd64.AppImage";
|
||||||
hash = "sha256-4RXEpNiD10hhZhBJ96lhvRG+K6ZrsEF+atwfkAicnhc=";
|
hash = "sha256-+wtKVCYUjDgXyL96oCqHC0ekWHIe9pLjn1RLBfWHamA=";
|
||||||
}
|
}
|
||||||
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.3/Donut_0.24.3_aarch64.AppImage";
|
url = "https://github.com/zhom/donutbrowser/releases/download/v0.25.1/Donut_0.25.1_aarch64.AppImage";
|
||||||
hash = "sha256-EmyJwfUnEQ3vtS2N99QrGrsNESHmiqIdGCrTYvTlMTI=";
|
hash = "sha256-fEmf8OzYG3XoEHwOVLh1mONDcJEGeW3d4bb3y//6gPs=";
|
||||||
}
|
}
|
||||||
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.4",
|
"version": "0.25.2",
|
||||||
"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
+69
-111
@@ -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",
|
||||||
@@ -214,7 +214,7 @@ dependencies = [
|
|||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.59.0",
|
||||||
"wl-clipboard-rs",
|
"wl-clipboard-rs",
|
||||||
"x11rb",
|
"x11rb",
|
||||||
]
|
]
|
||||||
@@ -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",
|
||||||
@@ -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.4"
|
version = "0.25.2"
|
||||||
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",
|
||||||
@@ -1840,7 +1840,6 @@ dependencies = [
|
|||||||
"smoltcp",
|
"smoltcp",
|
||||||
"sys-locale",
|
"sys-locale",
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
"tao",
|
|
||||||
"tar",
|
"tar",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
@@ -1861,7 +1860,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",
|
||||||
@@ -2938,9 +2936,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 +2996,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",
|
||||||
@@ -3087,7 +3085,7 @@ dependencies = [
|
|||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.62.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3431,9 +3429,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 +3442,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",
|
||||||
@@ -3649,43 +3647,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.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
|
checksum = "a76001fb4daed01e5f2b518aac0b4dc592e7c734da63dbffcf0c64fa612a8d0c"
|
||||||
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 +3688,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.29"
|
version = "0.4.30"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"value-bag",
|
"value-bag",
|
||||||
]
|
]
|
||||||
@@ -3820,9 +3799,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 +3849,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 +3902,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",
|
||||||
@@ -4109,7 +4087,7 @@ version = "0.7.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
|
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro-crate 3.5.0",
|
"proc-macro-crate 1.3.1",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
@@ -5345,9 +5323,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 +5496,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rusqlite"
|
name = "rusqlite"
|
||||||
version = "0.39.0"
|
version = "0.40.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
|
checksum = "1b3492ea85308705c3a5cc24fb9b9cf77273d30590349070db42991202b214c4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"fallible-iterator",
|
"fallible-iterator",
|
||||||
@@ -6133,9 +6111,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,9 +6225,9 @@ 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.60.2",
|
||||||
@@ -6324,9 +6302,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 +6446,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 +6584,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
|
"image",
|
||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -6619,7 +6598,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 +6611,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 +6952,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",
|
||||||
@@ -6998,7 +6977,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.3.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
@@ -7506,27 +7485,6 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.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]]
|
||||||
name = "tree_magic_mini"
|
name = "tree_magic_mini"
|
||||||
version = "3.2.2"
|
version = "3.2.2"
|
||||||
@@ -7584,9 +7542,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"
|
||||||
@@ -7844,9 +7802,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",
|
||||||
@@ -9083,9 +9041,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 +9076,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 +9102,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 +9309,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 +9323,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 +9336,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",
|
||||||
|
|||||||
+6
-12
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "donutbrowser"
|
name = "donutbrowser"
|
||||||
version = "0.24.4"
|
version = "0.25.2"
|
||||||
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"
|
||||||
@@ -87,7 +83,7 @@ cbc = "0.2"
|
|||||||
ring = "0.17"
|
ring = "0.17"
|
||||||
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 +94,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 +107,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 +139,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"
|
|
||||||
|
|
||||||
|
|||||||
+45
-22
@@ -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;
|
||||||
@@ -412,16 +411,9 @@ impl ApiServer {
|
|||||||
))
|
))
|
||||||
.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",
|
||||||
@@ -594,6 +586,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 +624,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 +678,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
|
||||||
@@ -735,6 +729,18 @@ 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(
|
||||||
@@ -784,10 +790,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 +895,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 a paid feature everywhere
|
||||||
|
// (GUI, API, MCP). Viewing it is free; mutating it is not.
|
||||||
|
if !crate::cloud_auth::CLOUD_AUTH
|
||||||
|
.has_active_paid_subscription()
|
||||||
|
.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) => {
|
||||||
@@ -1750,13 +1761,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
|
||||||
{
|
{
|
||||||
@@ -1820,6 +1833,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 +1846,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
|
||||||
|
.has_active_paid_subscription()
|
||||||
|
.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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -666,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,
|
||||||
@@ -673,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
|
||||||
@@ -817,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
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ 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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -413,7 +423,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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+33
-19
@@ -508,7 +508,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 +530,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": {
|
||||||
@@ -1671,9 +1671,15 @@ 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 and editing both require a paid plan.
|
||||||
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(arguments).await,
|
"get_profile_fingerprint" => {
|
||||||
"update_profile_fingerprint" => self.handle_update_profile_fingerprint(arguments).await,
|
Self::require_paid_subscription("Fingerprint").await?;
|
||||||
|
self.handle_get_profile_fingerprint(arguments).await
|
||||||
|
}
|
||||||
|
"update_profile_fingerprint" => {
|
||||||
|
Self::require_paid_subscription("Fingerprint").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)
|
||||||
@@ -1823,6 +1829,9 @@ 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 is a paid feature.
|
||||||
|
Self::require_paid_subscription("Launching a profile").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 +1841,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 +1885,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 +1913,9 @@ 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 is a paid feature.
|
||||||
|
Self::require_paid_subscription("Killing a profile").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())
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -510,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)?;
|
||||||
@@ -719,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);
|
||||||
@@ -773,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)?;
|
||||||
@@ -809,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)?;
|
||||||
@@ -838,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)?;
|
||||||
|
|
||||||
@@ -869,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)?;
|
||||||
|
|
||||||
@@ -895,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)?;
|
||||||
|
|
||||||
@@ -1058,6 +1068,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()),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.save_profile(&new_profile)?;
|
self.save_profile(&new_profile)?;
|
||||||
@@ -1225,6 +1236,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
|
||||||
@@ -1324,6 +1336,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)
|
||||||
@@ -1368,6 +1381,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);
|
||||||
@@ -2455,6 +2469,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(
|
||||||
@@ -2486,7 +2504,7 @@ pub async fn update_camoufox_config(
|
|||||||
.has_active_paid_subscription()
|
.has_active_paid_subscription()
|
||||||
.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
|
||||||
@@ -2514,7 +2532,7 @@ pub async fn update_wayfern_config(
|
|||||||
.has_active_paid_subscription()
|
.has_active_paid_subscription()
|
||||||
.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 {
|
||||||
|
|||||||
@@ -586,6 +586,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
|
||||||
@@ -668,6 +669,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 +728,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)?;
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -3154,6 +3173,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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+96
-101
@@ -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());
|
||||||
@@ -358,6 +363,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 +1497,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 +1536,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)
|
||||||
@@ -1579,21 +1629,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 +1668,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 +1829,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 +1865,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 +1967,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 +2003,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 +2159,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 +2199,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
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -703,6 +709,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 +788,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 +876,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 +1018,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 +1061,7 @@ impl WayfernManager {
|
|||||||
profilePath: Some(found_profile_path),
|
profilePath: Some(found_profile_path),
|
||||||
url: None,
|
url: None,
|
||||||
cdp_port,
|
cdp_port,
|
||||||
|
used_fingerprint: 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.4",
|
"version": "0.25.2",
|
||||||
"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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+124
-41
@@ -3,11 +3,13 @@
|
|||||||
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 { 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 +24,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 +41,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 +59,10 @@ 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 {
|
||||||
|
ONBOARDING_TOUR_FINISHED_EVENT,
|
||||||
|
setOnboardingActive,
|
||||||
|
} from "@/lib/onboarding-signal";
|
||||||
import {
|
import {
|
||||||
matchesGroupDigit,
|
matchesGroupDigit,
|
||||||
matchesShortcut,
|
matchesShortcut,
|
||||||
@@ -95,6 +103,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,
|
||||||
@@ -214,8 +311,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 +640,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 +872,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],
|
||||||
@@ -1189,9 +1269,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 +1311,6 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
checkStartupPrompt,
|
|
||||||
listenForUrlEvents,
|
listenForUrlEvents,
|
||||||
checkCurrentUrl,
|
checkCurrentUrl,
|
||||||
checkMissingBinaries,
|
checkMissingBinaries,
|
||||||
@@ -1336,11 +1412,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 +1428,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 +1449,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 +1511,7 @@ 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 />
|
||||||
<HomeHeader
|
<HomeHeader
|
||||||
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
@@ -1645,6 +1726,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 +1940,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}
|
||||||
|
|||||||
@@ -280,9 +280,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 &&
|
||||||
|
user.plan !== "free" &&
|
||||||
|
user.isPrimaryDevice === false && (
|
||||||
|
<p className="text-xs text-warning">
|
||||||
|
{t("account.automationPrimaryOnly")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isLoggedIn &&
|
||||||
|
user &&
|
||||||
|
user.plan !== "free" &&
|
||||||
|
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">
|
||||||
|
|||||||
@@ -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,7 +11,7 @@ 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 { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||||
@@ -307,6 +307,10 @@ export function CreateProfileDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
void loadSupportedBrowsers();
|
void loadSupportedBrowsers();
|
||||||
|
// Load downloaded versions for both anti-detect browsers up front so the
|
||||||
|
// selection-screen availability gate is accurate before either is picked.
|
||||||
|
void loadDownloadedVersions("wayfern");
|
||||||
|
void loadDownloadedVersions("camoufox");
|
||||||
// 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);
|
||||||
@@ -320,6 +324,7 @@ export function CreateProfileDialog({
|
|||||||
isOpen,
|
isOpen,
|
||||||
loadSupportedBrowsers,
|
loadSupportedBrowsers,
|
||||||
loadReleaseTypes,
|
loadReleaseTypes,
|
||||||
|
loadDownloadedVersions,
|
||||||
checkAndDownloadGeoIPDatabase,
|
checkAndDownloadGeoIPDatabase,
|
||||||
selectedBrowser,
|
selectedBrowser,
|
||||||
]);
|
]);
|
||||||
@@ -405,6 +410,7 @@ 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
|
||||||
@@ -585,7 +591,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,23 +624,30 @@ 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>
|
||||||
@@ -644,26 +657,41 @@ export function CreateProfileDialog({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleBrowserSelect("camoufox");
|
handleBrowserSelect("camoufox");
|
||||||
}}
|
}}
|
||||||
|
disabled={!getCreatableVersion("camoufox")}
|
||||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
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("camoufox") ? (
|
||||||
const IconComponent = getBrowserIcon("camoufox");
|
<LuLoaderCircle className="size-6 animate-spin" />
|
||||||
return IconComponent ? (
|
) : (
|
||||||
<IconComponent className="size-6" />
|
(() => {
|
||||||
) : null;
|
const IconComponent =
|
||||||
})()}
|
getBrowserIcon("camoufox");
|
||||||
|
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.firefoxLabel")}
|
{t("createProfile.firefoxLabel")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t("createProfile.firefoxSubtitle")}
|
{isBrowserCurrentlyDownloading("camoufox")
|
||||||
|
? t("createProfile.downloadingSubtitle")
|
||||||
|
: t("createProfile.firefoxSubtitle")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{!getCreatableVersion("wayfern") &&
|
||||||
|
!getCreatableVersion("camoufox") && (
|
||||||
|
<p className="pt-2 text-sm text-center text-muted-foreground">
|
||||||
|
{t("createProfile.browsersDownloading")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -867,7 +895,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 +927,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,7 +991,7 @@ export function CreateProfileDialog({
|
|||||||
crossOsUnlocked={crossOsUnlocked}
|
crossOsUnlocked={crossOsUnlocked}
|
||||||
limitedMode={!crossOsUnlocked}
|
limitedMode={!crossOsUnlocked}
|
||||||
profileVersion={
|
profileVersion={
|
||||||
getBestAvailableVersion("wayfern")?.version
|
getCreatableVersion("wayfern")?.version
|
||||||
}
|
}
|
||||||
profileBrowser="wayfern"
|
profileBrowser="wayfern"
|
||||||
/>
|
/>
|
||||||
@@ -975,7 +1039,7 @@ export function CreateProfileDialog({
|
|||||||
{!isLoadingReleaseTypes &&
|
{!isLoadingReleaseTypes &&
|
||||||
!releaseTypesError &&
|
!releaseTypesError &&
|
||||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
!isBrowserCurrentlyDownloading("camoufox") &&
|
||||||
!isBrowserVersionAvailable("camoufox") &&
|
!getCreatableVersion("camoufox") &&
|
||||||
getBestAvailableVersion("camoufox") && (
|
getBestAvailableVersion("camoufox") && (
|
||||||
<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">
|
||||||
@@ -1007,17 +1071,53 @@ export function CreateProfileDialog({
|
|||||||
{!isLoadingReleaseTypes &&
|
{!isLoadingReleaseTypes &&
|
||||||
!releaseTypesError &&
|
!releaseTypesError &&
|
||||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
!isBrowserCurrentlyDownloading("camoufox") &&
|
||||||
isBrowserVersionAvailable("camoufox") && (
|
getCreatableVersion("camoufox") && (
|
||||||
<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: "Camoufox",
|
browser: "Camoufox",
|
||||||
version:
|
version:
|
||||||
getBestAvailableVersion("camoufox")
|
getCreatableVersion("camoufox")?.version,
|
||||||
?.version,
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isLoadingReleaseTypes &&
|
||||||
|
!releaseTypesError &&
|
||||||
|
!isBrowserCurrentlyDownloading("camoufox") &&
|
||||||
|
getCreatableVersion("camoufox") &&
|
||||||
|
!isBrowserVersionAvailable("camoufox") &&
|
||||||
|
getBestAvailableVersion("camoufox") && (
|
||||||
|
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||||
|
<p className="flex-1 text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"createProfile.version.upgradeAvailable",
|
||||||
|
{
|
||||||
|
browser: "Camoufox",
|
||||||
|
version:
|
||||||
|
getBestAvailableVersion("camoufox")
|
||||||
|
?.version,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<LoadingButton
|
||||||
|
onClick={() => {
|
||||||
|
void handleDownload("camoufox");
|
||||||
|
}}
|
||||||
|
isLoading={isBrowserCurrentlyDownloading(
|
||||||
|
"camoufox",
|
||||||
|
)}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isBrowserCurrentlyDownloading(
|
||||||
|
"camoufox",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isBrowserCurrentlyDownloading("camoufox")
|
||||||
|
? t("common.buttons.downloading")
|
||||||
|
: t("common.buttons.download")}
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isBrowserCurrentlyDownloading("camoufox") && (
|
{isBrowserCurrentlyDownloading("camoufox") && (
|
||||||
<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", {
|
||||||
@@ -1045,7 +1145,7 @@ export function CreateProfileDialog({
|
|||||||
crossOsUnlocked={crossOsUnlocked}
|
crossOsUnlocked={crossOsUnlocked}
|
||||||
limitedMode={!crossOsUnlocked}
|
limitedMode={!crossOsUnlocked}
|
||||||
profileVersion={
|
profileVersion={
|
||||||
getBestAvailableVersion("camoufox")?.version
|
getCreatableVersion("camoufox")?.version
|
||||||
}
|
}
|
||||||
profileBrowser="camoufox"
|
profileBrowser="camoufox"
|
||||||
/>
|
/>
|
||||||
@@ -1077,7 +1177,7 @@ export function CreateProfileDialog({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
Retry
|
{t("common.buttons.retry")}
|
||||||
</RippleButton>
|
</RippleButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1086,7 +1186,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 +1222,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 +1529,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 +1555,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 +1591,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 +1797,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}>
|
||||||
|
|||||||
@@ -174,42 +174,38 @@ 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":
|
case "twilight-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 "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 +228,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" />
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -303,7 +303,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>
|
||||||
)}
|
)}
|
||||||
@@ -604,7 +604,7 @@ export function ImportProfileDialog({
|
|||||||
|
|
||||||
<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={
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
LuGroup,
|
LuGroup,
|
||||||
LuKey,
|
LuKey,
|
||||||
LuLink,
|
LuLink,
|
||||||
|
LuLock,
|
||||||
LuLockOpen,
|
LuLockOpen,
|
||||||
LuPlus,
|
LuPlus,
|
||||||
LuPuzzle,
|
LuPuzzle,
|
||||||
@@ -341,7 +342,9 @@ export function ProfileInfoDialog({
|
|||||||
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,
|
||||||
},
|
},
|
||||||
@@ -481,6 +484,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}
|
||||||
@@ -888,6 +894,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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1586,11 +1593,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 +1638,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,6 +1681,8 @@ 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(String(e));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import confetti from "canvas-confetti";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Logo } from "@/components/icons/logo";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
const spring = { type: "spring", stiffness: 240, damping: 22 } as const;
|
||||||
|
|
||||||
|
// Celebratory close-out of the first-run onboarding: thanks the user and fires
|
||||||
|
// confetti. Shown once the product tour is finished.
|
||||||
|
export function ThankYouDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const fire = (options: confetti.Options) => {
|
||||||
|
void confetti({ origin: { y: 0.7 }, ...options });
|
||||||
|
};
|
||||||
|
fire({ particleCount: 110, spread: 70, startVelocity: 48 });
|
||||||
|
const t1 = setTimeout(
|
||||||
|
() => fire({ particleCount: 70, spread: 100, decay: 0.92 }),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
const t2 = setTimeout(
|
||||||
|
() => fire({ particleCount: 50, spread: 120, scalar: 0.9 }),
|
||||||
|
420,
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(t1);
|
||||||
|
clearTimeout(t2);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<div className="flex flex-col items-center gap-6 text-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.6, rotate: -12 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, rotate: 0 }}
|
||||||
|
transition={{ ...spring, delay: 0.05 }}
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
<Logo className="size-14" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<DialogTitle className="text-2xl font-semibold tracking-tight text-balance">
|
||||||
|
{t("onboarding.thankYou.title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ ...spring, delay: 0.15 }}
|
||||||
|
className="mx-auto max-w-[46ch] text-sm leading-6 text-pretty text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t("onboarding.thankYou.body")}
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="sm" onClick={onClose}>
|
||||||
|
{t("onboarding.thankYou.cta")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -312,7 +312,7 @@ export const ColorPickerAlpha = ({
|
|||||||
'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==") left center',
|
'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==") left center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent rounded-full to-black/50" />
|
<div className="absolute inset-0 bg-linear-to-r from-transparent rounded-full to-black/50" />
|
||||||
<Slider.Range className="absolute h-full bg-transparent rounded-full" />
|
<Slider.Range className="absolute h-full bg-transparent rounded-full" />
|
||||||
</Slider.Track>
|
</Slider.Track>
|
||||||
<Slider.Thumb className="block size-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
<Slider.Thumb className="block size-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||||
|
|||||||
@@ -111,26 +111,39 @@ function DialogOverlay({
|
|||||||
className={cn("fixed inset-0 z-9999 bg-background/50", className)}
|
className={cn("fixed inset-0 z-9999 bg-background/50", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
{/* Keep the OS title-bar zone draggable while a modal is open — the
|
||||||
|
overlay otherwise covers the native drag region. `data-window-drag-area`
|
||||||
|
stops Radix from treating a drag here as an outside-click dismiss. */}
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
data-window-drag-area="true"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-x-0 top-0 h-11"
|
||||||
|
/>
|
||||||
<WindowDragArea />
|
<WindowDragArea />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</DialogPrimitive.Overlay>
|
</DialogPrimitive.Overlay>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type DialogFlipDirection = "top" | "bottom" | "left" | "right";
|
|
||||||
|
|
||||||
type DialogContentProps = Omit<
|
type DialogContentProps = Omit<
|
||||||
React.ComponentProps<typeof DialogPrimitive.Content>,
|
React.ComponentProps<typeof DialogPrimitive.Content>,
|
||||||
"forceMount" | "asChild"
|
"forceMount" | "asChild"
|
||||||
> &
|
> &
|
||||||
HTMLMotionProps<"div"> & {
|
HTMLMotionProps<"div"> & {
|
||||||
from?: DialogFlipDirection;
|
|
||||||
/**
|
/**
|
||||||
* Suppress the built-in top-right close X. Use when the dialog renders
|
* Suppress the built-in top-right close X. Use when the dialog renders
|
||||||
* its own header bar with a custom close control to avoid two X buttons
|
* its own header bar with a custom close control to avoid two X buttons
|
||||||
* stacking near the corner.
|
* stacking near the corner.
|
||||||
*/
|
*/
|
||||||
hideClose?: boolean;
|
hideClose?: boolean;
|
||||||
|
/**
|
||||||
|
* When false, the user cannot dismiss the dialog — Escape and outside
|
||||||
|
* clicks are ignored and the close X is hidden. Use for steps the user
|
||||||
|
* must complete to progress (e.g. required onboarding, a blocking
|
||||||
|
* download). The dialog can still be closed programmatically via `open`.
|
||||||
|
*/
|
||||||
|
dismissible?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function SubPageContent({
|
function SubPageContent({
|
||||||
@@ -176,7 +189,6 @@ function SubPageContent({
|
|||||||
function DialogContent({
|
function DialogContent({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
from = "top",
|
|
||||||
onOpenAutoFocus,
|
onOpenAutoFocus,
|
||||||
onCloseAutoFocus,
|
onCloseAutoFocus,
|
||||||
onEscapeKeyDown,
|
onEscapeKeyDown,
|
||||||
@@ -184,19 +196,11 @@ function DialogContent({
|
|||||||
onInteractOutside,
|
onInteractOutside,
|
||||||
transition,
|
transition,
|
||||||
hideClose,
|
hideClose,
|
||||||
|
dismissible = true,
|
||||||
...props
|
...props
|
||||||
}: DialogContentProps) {
|
}: DialogContentProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { subPage } = useDialog();
|
const { subPage } = useDialog();
|
||||||
const initialRotation =
|
|
||||||
from === "bottom" || from === "left" ? "20deg" : "-20deg";
|
|
||||||
const isVertical = from === "top" || from === "bottom";
|
|
||||||
const rotateAxis = isVertical ? "rotateX" : "rotateY";
|
|
||||||
const finalTransition = transition ?? {
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 220,
|
|
||||||
damping: 26,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (subPage) {
|
if (subPage) {
|
||||||
return <SubPageContent>{children}</SubPageContent>;
|
return <SubPageContent>{children}</SubPageContent>;
|
||||||
@@ -210,9 +214,16 @@ function DialogContent({
|
|||||||
forceMount
|
forceMount
|
||||||
onOpenAutoFocus={onOpenAutoFocus}
|
onOpenAutoFocus={onOpenAutoFocus}
|
||||||
onCloseAutoFocus={onCloseAutoFocus}
|
onCloseAutoFocus={onCloseAutoFocus}
|
||||||
onEscapeKeyDown={onEscapeKeyDown}
|
onEscapeKeyDown={(event) => {
|
||||||
|
if (!dismissible) event.preventDefault();
|
||||||
|
onEscapeKeyDown?.(event);
|
||||||
|
}}
|
||||||
onPointerDownOutside={onPointerDownOutside}
|
onPointerDownOutside={onPointerDownOutside}
|
||||||
onInteractOutside={(event) => {
|
onInteractOutside={(event) => {
|
||||||
|
if (!dismissible) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const target = event.target as HTMLElement | null;
|
const target = event.target as HTMLElement | null;
|
||||||
if (target?.closest('[data-window-drag-area="true"]')) {
|
if (target?.closest('[data-window-drag-area="true"]')) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -223,22 +234,25 @@ function DialogContent({
|
|||||||
<motion.div
|
<motion.div
|
||||||
key="dialog-content"
|
key="dialog-content"
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
initial={{
|
// Open/close motion modeled on transitions.dev's modal: a subtle
|
||||||
opacity: 0,
|
// scale from 0.96 → 1 with opacity, eased with cubic-bezier(0.22, 1,
|
||||||
filter: "blur(4px)",
|
// 0.36, 1). Open is 250ms; close is a quicker 150ms. The centering
|
||||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
// translate stays in `style` so `scale` animates around the center
|
||||||
}}
|
// without fighting the transform-based positioning.
|
||||||
animate={{
|
style={{ transformOrigin: "center" }}
|
||||||
opacity: 1,
|
initial={{ opacity: 0, scale: 0.96 }}
|
||||||
filter: "blur(0px)",
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
transform: `perspective(500px) ${rotateAxis}(0deg) scale(1)`,
|
|
||||||
}}
|
|
||||||
exit={{
|
exit={{
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
filter: "blur(4px)",
|
scale: 0.96,
|
||||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
transition: transition ?? {
|
||||||
|
duration: 0.15,
|
||||||
|
ease: [0.22, 1, 0.36, 1],
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
transition={finalTransition}
|
transition={
|
||||||
|
transition ?? { duration: 0.25, ease: [0.22, 1, 0.36, 1] }
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg",
|
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg",
|
||||||
className,
|
className,
|
||||||
@@ -246,7 +260,7 @@ function DialogContent({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{!hideClose && (
|
{!hideClose && dismissible && (
|
||||||
<DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
<DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||||
<RxCross2 />
|
<RxCross2 />
|
||||||
<span className="sr-only">{t("common.buttons.close")}</span>
|
<span className="sr-only">{t("common.buttons.close")}</span>
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
"--normal-bg": "var(--card)",
|
"--normal-bg": "var(--card)",
|
||||||
"--normal-text": "var(--card-foreground)",
|
"--normal-text": "var(--card-foreground)",
|
||||||
"--normal-border": "var(--border)",
|
"--normal-border": "var(--border)",
|
||||||
zIndex: 99999,
|
zIndex: 10001,
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
style: {
|
style: {
|
||||||
zIndex: 99999,
|
zIndex: 10001,
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
backdropFilter: "saturate(1.2)",
|
backdropFilter: "saturate(1.2)",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -302,7 +302,9 @@ export function WayfernConfigForm({
|
|||||||
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">
|
||||||
@@ -334,7 +336,9 @@ export function WayfernConfigForm({
|
|||||||
e.target.value || undefined,
|
e.target.value || undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 10.0.0"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "10.0.0",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -348,7 +352,9 @@ export function WayfernConfigForm({
|
|||||||
e.target.value || undefined,
|
e.target.value || undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., Google Chrome"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "Google Chrome",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -364,7 +370,9 @@ export function WayfernConfigForm({
|
|||||||
e.target.value || undefined,
|
e.target.value || undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 143"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "143",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -388,7 +396,7 @@ export function WayfernConfigForm({
|
|||||||
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">
|
||||||
@@ -405,7 +413,7 @@ export function WayfernConfigForm({
|
|||||||
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">
|
||||||
@@ -422,7 +430,7 @@ export function WayfernConfigForm({
|
|||||||
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>
|
</div>
|
||||||
@@ -446,7 +454,9 @@ export function WayfernConfigForm({
|
|||||||
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">
|
||||||
@@ -463,7 +473,9 @@ export function WayfernConfigForm({
|
|||||||
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">
|
||||||
@@ -481,7 +493,9 @@ export function WayfernConfigForm({
|
|||||||
e.target.value ? parseFloat(e.target.value) : undefined,
|
e.target.value ? parseFloat(e.target.value) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 1.0"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "1.0",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -498,7 +512,9 @@ export function WayfernConfigForm({
|
|||||||
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">
|
||||||
@@ -515,7 +531,9 @@ export function WayfernConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 1040"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "1040",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -532,7 +550,9 @@ export function WayfernConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 24"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "24",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -556,7 +576,9 @@ export function WayfernConfigForm({
|
|||||||
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">
|
||||||
@@ -573,7 +595,9 @@ export function WayfernConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 1040"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "1040",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -590,7 +614,9 @@ export function WayfernConfigForm({
|
|||||||
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">
|
||||||
@@ -607,7 +633,9 @@ export function WayfernConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 940"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "940",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -622,7 +650,7 @@ export function WayfernConfigForm({
|
|||||||
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">
|
||||||
@@ -637,7 +665,7 @@ export function WayfernConfigForm({
|
|||||||
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>
|
||||||
@@ -660,7 +688,9 @@ export function WayfernConfigForm({
|
|||||||
e.target.value || undefined,
|
e.target.value || undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., en-US"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "en-US",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -740,7 +770,9 @@ export function WayfernConfigForm({
|
|||||||
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 className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -775,7 +807,9 @@ export function WayfernConfigForm({
|
|||||||
e.target.value ? parseFloat(e.target.value) : undefined,
|
e.target.value ? parseFloat(e.target.value) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 40.7128"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "40.7128",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -791,7 +825,9 @@ export function WayfernConfigForm({
|
|||||||
e.target.value ? parseFloat(e.target.value) : undefined,
|
e.target.value ? parseFloat(e.target.value) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., -74.0060"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "-74.0060",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -806,7 +842,9 @@ export function WayfernConfigForm({
|
|||||||
e.target.value ? parseFloat(e.target.value) : undefined,
|
e.target.value ? parseFloat(e.target.value) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 100"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "100",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -829,7 +867,9 @@ export function WayfernConfigForm({
|
|||||||
e.target.value || undefined,
|
e.target.value || undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., Intel"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "Intel",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -926,7 +966,9 @@ export function WayfernConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 48000"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "48000",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -943,7 +985,7 @@ export function WayfernConfigForm({
|
|||||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 2"
|
placeholder={t("common.placeholders.example", { value: "2" })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -987,7 +1029,9 @@ export function WayfernConfigForm({
|
|||||||
e.target.value ? parseFloat(e.target.value) : undefined,
|
e.target.value ? parseFloat(e.target.value) : undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 0.85"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "0.85",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1008,7 +1052,9 @@ export function WayfernConfigForm({
|
|||||||
e.target.value || undefined,
|
e.target.value || undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., Google Inc."
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "Google Inc.",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1038,7 +1084,9 @@ export function WayfernConfigForm({
|
|||||||
e.target.value || undefined,
|
e.target.value || undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 20030107"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "20030107",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1047,10 +1095,10 @@ export function WayfernConfigForm({
|
|||||||
{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 />
|
||||||
@@ -1197,7 +1245,9 @@ export function WayfernConfigForm({
|
|||||||
: 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">
|
||||||
@@ -1216,7 +1266,9 @@ export function WayfernConfigForm({
|
|||||||
: 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">
|
||||||
@@ -1235,7 +1287,9 @@ export function WayfernConfigForm({
|
|||||||
: 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">
|
||||||
@@ -1254,7 +1308,9 @@ export function WayfernConfigForm({
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., 600"
|
placeholder={t("common.placeholders.example", {
|
||||||
|
value: "600",
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1262,10 +1318,10 @@ export function WayfernConfigForm({
|
|||||||
{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 />
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
LuArrowRight,
|
||||||
|
LuBriefcase,
|
||||||
|
LuCookie,
|
||||||
|
LuFolders,
|
||||||
|
LuGithub,
|
||||||
|
LuGlobe,
|
||||||
|
LuHeart,
|
||||||
|
LuLoaderCircle,
|
||||||
|
LuMic,
|
||||||
|
LuNetwork,
|
||||||
|
LuShieldCheck,
|
||||||
|
LuTerminal,
|
||||||
|
LuTriangleAlert,
|
||||||
|
LuUsers,
|
||||||
|
} from "react-icons/lu";
|
||||||
|
import { Logo } from "@/components/icons/logo";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { useBrowserSetup } from "@/hooks/use-browser-setup";
|
||||||
|
import { usePermissions } from "@/hooks/use-permissions";
|
||||||
|
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||||
|
|
||||||
|
type WelcomeStep = "intro" | "license" | "permissions" | "setup";
|
||||||
|
|
||||||
|
const panelTransition = {
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 260,
|
||||||
|
damping: 28,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const panelVariants = {
|
||||||
|
enter: { opacity: 0, y: 12 },
|
||||||
|
center: { opacity: 1, y: 0 },
|
||||||
|
exit: { opacity: 0, y: -12 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Concrete feature list shown on the intro step, rendered as an icon grid.
|
||||||
|
const FEATURES = [
|
||||||
|
{ key: "welcome.features.items.setDefault", Icon: LuGlobe },
|
||||||
|
{ key: "welcome.features.items.proxy", Icon: LuNetwork },
|
||||||
|
{ key: "welcome.features.items.vpn", Icon: LuShieldCheck },
|
||||||
|
{ key: "welcome.features.items.profiles", Icon: LuUsers },
|
||||||
|
{ key: "welcome.features.items.api", Icon: LuTerminal },
|
||||||
|
{ key: "welcome.features.items.openSource", Icon: LuGithub },
|
||||||
|
{ key: "welcome.features.items.groups", Icon: LuFolders },
|
||||||
|
{ key: "welcome.features.items.cookies", Icon: LuCookie },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (!(bytes > 0)) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
const exponent = Math.min(
|
||||||
|
units.length - 1,
|
||||||
|
Math.floor(Math.log(bytes) / Math.log(1024)),
|
||||||
|
);
|
||||||
|
const value = bytes / 1024 ** exponent;
|
||||||
|
const rounded = exponent === 0 ? value : Math.round(value * 10) / 10;
|
||||||
|
return `${rounded} ${units[exponent]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const total = Math.max(0, Math.round(seconds));
|
||||||
|
if (total < 60) return `${total}s`;
|
||||||
|
const minutes = Math.floor(total / 60);
|
||||||
|
const remainder = total % 60;
|
||||||
|
return `${minutes}m ${String(remainder).padStart(2, "0")}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WelcomeDialog({
|
||||||
|
isOpen,
|
||||||
|
needsSetup,
|
||||||
|
onComplete,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
/**
|
||||||
|
* Whether this user still needs the browser-download + profile-creation flow.
|
||||||
|
* False when they already have a profile — then the welcome and commercial-use
|
||||||
|
* steps still show, but "continue" finishes onboarding instead of proceeding
|
||||||
|
* to permissions/download.
|
||||||
|
*/
|
||||||
|
needsSetup: boolean;
|
||||||
|
onComplete: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { requestPermission } = usePermissions();
|
||||||
|
const [step, setStep] = useState<WelcomeStep>("intro");
|
||||||
|
// Where the "skip" / "continue" affordances go: into the setup flow when a
|
||||||
|
// browser/profile is still needed, otherwise straight to completion.
|
||||||
|
const advanceToSetup = () => {
|
||||||
|
if (needsSetup) setStep("setup");
|
||||||
|
else onComplete();
|
||||||
|
};
|
||||||
|
const [requesting, setRequesting] = useState(false);
|
||||||
|
|
||||||
|
// Track the required browser's download + extraction the whole time the
|
||||||
|
// dialog is open, so progress is live by the time the user reaches setup.
|
||||||
|
const setup = useBrowserSetup("wayfern", isOpen);
|
||||||
|
const browserName = getBrowserDisplayName("wayfern");
|
||||||
|
|
||||||
|
const requestPermissions = useCallback(async () => {
|
||||||
|
setRequesting(true);
|
||||||
|
try {
|
||||||
|
await requestPermission("microphone");
|
||||||
|
await requestPermission("camera");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Permission request failed:", err);
|
||||||
|
} finally {
|
||||||
|
setRequesting(false);
|
||||||
|
setStep("setup");
|
||||||
|
}
|
||||||
|
}, [requestPermission]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={() => {}}>
|
||||||
|
<DialogContent
|
||||||
|
dismissible={false}
|
||||||
|
className="overflow-hidden sm:max-w-xl"
|
||||||
|
>
|
||||||
|
<DialogTitle className="sr-only">{t("welcome.title")}</DialogTitle>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{step === "intro" && (
|
||||||
|
<motion.div
|
||||||
|
key="intro"
|
||||||
|
variants={panelVariants}
|
||||||
|
initial="enter"
|
||||||
|
animate="center"
|
||||||
|
exit="exit"
|
||||||
|
transition={panelTransition}
|
||||||
|
className="flex flex-col gap-7"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ ...panelTransition, delay: 0.05 }}
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
<Logo className="size-12" />
|
||||||
|
</motion.div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight text-balance">
|
||||||
|
{t("welcome.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto max-w-[55ch] text-sm text-pretty text-muted-foreground">
|
||||||
|
{t("welcome.tagline")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t("welcome.features.title")}
|
||||||
|
</p>
|
||||||
|
<dl className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||||
|
{FEATURES.map(({ key, Icon }, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={key}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{
|
||||||
|
...panelTransition,
|
||||||
|
delay: 0.12 + i * 0.04,
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2.5"
|
||||||
|
>
|
||||||
|
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<dt className="text-sm font-medium text-foreground">
|
||||||
|
{t(key)}
|
||||||
|
</dt>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={advanceToSetup}
|
||||||
|
>
|
||||||
|
{t("welcome.skip")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={() => setStep("license")}
|
||||||
|
>
|
||||||
|
{t("welcome.next")}
|
||||||
|
<LuArrowRight className="size-4 shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "license" && (
|
||||||
|
<motion.div
|
||||||
|
key="license"
|
||||||
|
variants={panelVariants}
|
||||||
|
initial="enter"
|
||||||
|
animate="center"
|
||||||
|
exit="exit"
|
||||||
|
transition={panelTransition}
|
||||||
|
className="flex flex-col gap-7"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2 text-center">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight text-balance">
|
||||||
|
{t("welcome.license.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto max-w-[55ch] text-sm leading-6 text-pretty text-muted-foreground">
|
||||||
|
{t("welcome.license.body")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border p-4">
|
||||||
|
<LuHeart className="mt-0.5 size-4 shrink-0 text-success" />
|
||||||
|
<div className="flex flex-col gap-0.5 text-left">
|
||||||
|
<dt className="text-sm font-medium text-foreground">
|
||||||
|
{t("welcome.license.personalTitle")}
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm text-pretty text-muted-foreground">
|
||||||
|
{t("welcome.license.personalDesc")}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border p-4">
|
||||||
|
<LuBriefcase className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex flex-col gap-0.5 text-left">
|
||||||
|
<dt className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
|
{t("welcome.license.commercialTitle")}
|
||||||
|
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||||
|
{t("welcome.license.trialBadge")}
|
||||||
|
</span>
|
||||||
|
</dt>
|
||||||
|
<dd className="text-sm text-pretty text-muted-foreground">
|
||||||
|
{t("welcome.license.commercialDesc")}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={advanceToSetup}
|
||||||
|
>
|
||||||
|
{t("welcome.skip")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={() => {
|
||||||
|
if (needsSetup) setStep("permissions");
|
||||||
|
else onComplete();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("welcome.license.agree")}
|
||||||
|
<LuArrowRight className="size-4 shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "permissions" && (
|
||||||
|
<motion.div
|
||||||
|
key="permissions"
|
||||||
|
variants={panelVariants}
|
||||||
|
initial="enter"
|
||||||
|
animate="center"
|
||||||
|
exit="exit"
|
||||||
|
transition={panelTransition}
|
||||||
|
className="flex flex-col gap-7"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2 text-center">
|
||||||
|
<h2 className="flex items-center justify-center gap-2 text-2xl font-semibold tracking-tight text-balance">
|
||||||
|
<LuMic className="size-5 shrink-0" />
|
||||||
|
{t("welcome.permissions.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto max-w-[55ch] text-sm leading-6 text-pretty text-muted-foreground">
|
||||||
|
{t("welcome.permissions.desc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
disabled={requesting}
|
||||||
|
onClick={advanceToSetup}
|
||||||
|
>
|
||||||
|
{t("welcome.permissions.skip")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
|
disabled={requesting}
|
||||||
|
onClick={() => {
|
||||||
|
void requestPermissions();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{requesting && (
|
||||||
|
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
|
||||||
|
)}
|
||||||
|
{requesting
|
||||||
|
? t("welcome.permissions.requesting")
|
||||||
|
: t("welcome.permissions.grant")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "setup" && (
|
||||||
|
<motion.div
|
||||||
|
key="setup"
|
||||||
|
variants={panelVariants}
|
||||||
|
initial="enter"
|
||||||
|
animate="center"
|
||||||
|
exit="exit"
|
||||||
|
transition={panelTransition}
|
||||||
|
className="flex flex-col items-center gap-6 text-center"
|
||||||
|
>
|
||||||
|
{setup.phase === "error" ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<h2 className="flex items-center justify-center gap-2 text-2xl font-semibold tracking-tight text-balance text-destructive">
|
||||||
|
<LuTriangleAlert className="size-5 shrink-0" />
|
||||||
|
{t("welcome.ready.errorTitle")}
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-[55ch] text-sm leading-6 text-pretty text-muted-foreground">
|
||||||
|
{setup.error?.stage === "downloading"
|
||||||
|
? t("welcome.ready.errorDownload", {
|
||||||
|
browser: browserName,
|
||||||
|
})
|
||||||
|
: setup.error?.stage === "extracting" ||
|
||||||
|
setup.error?.stage === "verifying"
|
||||||
|
? t("welcome.ready.errorExtraction", {
|
||||||
|
browser: browserName,
|
||||||
|
})
|
||||||
|
: t("welcome.ready.errorGeneric", {
|
||||||
|
browser: browserName,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* No escape hatch here: a browser must finish downloading
|
||||||
|
before onboarding can complete, so the only action on
|
||||||
|
failure is to retry. */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setup.retry();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("welcome.ready.retry")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight text-balance">
|
||||||
|
{t("welcome.ready.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-[55ch] text-sm leading-6 text-pretty text-muted-foreground">
|
||||||
|
{setup.phase === "ready"
|
||||||
|
? t("welcome.ready.descReady")
|
||||||
|
: setup.phase === "extracting"
|
||||||
|
? t("welcome.ready.descExtracting")
|
||||||
|
: t("welcome.ready.descDownloading")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{setup.phase === "downloading" && (
|
||||||
|
<div className="flex w-full max-w-xs flex-col gap-2">
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<motion.div
|
||||||
|
className="h-full rounded-full bg-primary"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{
|
||||||
|
width: `${Math.max(setup.downloadPercent, 4)}%`,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 120,
|
||||||
|
damping: 24,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm tabular-nums text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
|
||||||
|
{t("welcome.ready.downloading")}
|
||||||
|
</span>
|
||||||
|
<span>{setup.downloadPercent}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-0.5 text-xs tabular-nums text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{setup.totalBytes != null
|
||||||
|
? t("welcome.ready.stats", {
|
||||||
|
downloaded: formatBytes(setup.downloadedBytes),
|
||||||
|
total: formatBytes(setup.totalBytes),
|
||||||
|
})
|
||||||
|
: formatBytes(setup.downloadedBytes)}
|
||||||
|
</span>
|
||||||
|
{setup.speedBytesPerSec > 0 && (
|
||||||
|
<span>
|
||||||
|
{t("welcome.ready.speed", {
|
||||||
|
speed: formatBytes(setup.speedBytesPerSec),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{setup.etaSeconds != null &&
|
||||||
|
Number.isFinite(setup.etaSeconds) &&
|
||||||
|
setup.etaSeconds > 0 && (
|
||||||
|
<span>
|
||||||
|
{t("welcome.ready.timeLeft", {
|
||||||
|
time: formatDuration(setup.etaSeconds),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{setup.phase === "extracting" && (
|
||||||
|
<div className="flex w-full max-w-xs flex-col gap-2">
|
||||||
|
{setup.extractionOvertime ? (
|
||||||
|
<div className="flex items-center justify-center gap-1.5 text-sm tabular-nums text-muted-foreground">
|
||||||
|
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
|
||||||
|
{t("welcome.ready.almostFinished")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<motion.div
|
||||||
|
className="h-full rounded-full bg-primary"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{
|
||||||
|
width: `${Math.max(setup.extractionPercent, 4)}%`,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 120,
|
||||||
|
damping: 24,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm tabular-nums text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<LuLoaderCircle className="size-4 shrink-0 animate-spin" />
|
||||||
|
{t("welcome.ready.extracting")}
|
||||||
|
</span>
|
||||||
|
<span>{setup.extractionPercent}%</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{setup.phase === "ready" && (
|
||||||
|
<Button size="sm" className="gap-1.5" onClick={onComplete}>
|
||||||
|
<LuArrowRight className="size-4 shrink-0" />
|
||||||
|
{t("welcome.ready.cta")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ export function useAppUpdateNotifications() {
|
|||||||
percentage: 0,
|
percentage: 0,
|
||||||
speed: undefined,
|
speed: undefined,
|
||||||
eta: undefined,
|
eta: undefined,
|
||||||
message: "Starting update...",
|
message: t("appUpdate.toast.startingUpdate"),
|
||||||
});
|
});
|
||||||
|
|
||||||
await invoke("download_and_prepare_app_update", {
|
await invoke("download_and_prepare_app_update", {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { listen } from "@tauri-apps/api/event";
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||||
|
import { isOnboardingActive } from "@/lib/onboarding-signal";
|
||||||
import {
|
import {
|
||||||
dismissToast,
|
dismissToast,
|
||||||
showDownloadToast,
|
showDownloadToast,
|
||||||
@@ -327,31 +328,39 @@ export function useBrowserDownload() {
|
|||||||
: i18n.t("browserDownload.toast.calculating");
|
: i18n.t("browserDownload.toast.calculating");
|
||||||
|
|
||||||
const toastId = `download-${browserName.toLowerCase()}-${progress.version}`;
|
const toastId = `download-${browserName.toLowerCase()}-${progress.version}`;
|
||||||
showDownloadToast(
|
// During first-run onboarding the welcome dialog shows browser
|
||||||
browserName,
|
// setup progress itself, so suppress the global download toast.
|
||||||
progress.version,
|
if (!isOnboardingActive()) {
|
||||||
"downloading",
|
showDownloadToast(
|
||||||
{
|
browserName,
|
||||||
percentage: progress.percentage,
|
progress.version,
|
||||||
speed: speedMBps,
|
"downloading",
|
||||||
eta: etaText,
|
{
|
||||||
},
|
percentage: progress.percentage,
|
||||||
{
|
speed: speedMBps,
|
||||||
onCancel: () => {
|
eta: etaText,
|
||||||
invoke("cancel_download", {
|
|
||||||
browserStr: progress.browser,
|
|
||||||
version: progress.version,
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error("Failed to cancel download:", err);
|
|
||||||
});
|
|
||||||
dismissToast(toastId);
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
);
|
onCancel: () => {
|
||||||
|
invoke("cancel_download", {
|
||||||
|
browserStr: progress.browser,
|
||||||
|
version: progress.version,
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error("Failed to cancel download:", err);
|
||||||
|
});
|
||||||
|
dismissToast(toastId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (progress.stage === "extracting") {
|
} else if (progress.stage === "extracting") {
|
||||||
showDownloadToast(browserName, progress.version, "extracting");
|
if (!isOnboardingActive()) {
|
||||||
|
showDownloadToast(browserName, progress.version, "extracting");
|
||||||
|
}
|
||||||
} else if (progress.stage === "verifying") {
|
} else if (progress.stage === "verifying") {
|
||||||
showDownloadToast(browserName, progress.version, "verifying");
|
if (!isOnboardingActive()) {
|
||||||
|
showDownloadToast(browserName, progress.version, "verifying");
|
||||||
|
}
|
||||||
} else if (progress.stage === "cancelled") {
|
} else if (progress.stage === "cancelled") {
|
||||||
setDownloadingBrowsers((prev) => {
|
setDownloadingBrowsers((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -372,17 +381,21 @@ export function useBrowserDownload() {
|
|||||||
`download-${browserName.toLowerCase()}-${progress.version}`,
|
`download-${browserName.toLowerCase()}-${progress.version}`,
|
||||||
);
|
);
|
||||||
setDownloadProgress(null);
|
setDownloadProgress(null);
|
||||||
showErrorToast(
|
// During first-run onboarding the welcome dialog surfaces a
|
||||||
i18n.t("browserDownload.toast.extractionFailed", {
|
// concrete setup error itself, so suppress the global toast.
|
||||||
browser: browserName,
|
if (!isOnboardingActive()) {
|
||||||
version: progress.version,
|
showErrorToast(
|
||||||
}),
|
i18n.t("browserDownload.toast.extractionFailed", {
|
||||||
{
|
browser: browserName,
|
||||||
description: i18n.t(
|
version: progress.version,
|
||||||
"browserDownload.toast.extractionFailedDescription",
|
}),
|
||||||
),
|
{
|
||||||
},
|
description: i18n.t(
|
||||||
);
|
"browserDownload.toast.extractionFailedDescription",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (progress.stage === "completed") {
|
} else if (progress.stage === "completed") {
|
||||||
setDownloadingBrowsers((prev) => {
|
setDownloadingBrowsers((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -401,7 +414,9 @@ export function useBrowserDownload() {
|
|||||||
} catch {
|
} catch {
|
||||||
/* empty */
|
/* empty */
|
||||||
}
|
}
|
||||||
showDownloadToast(browserName, progress.version, "completed");
|
if (!isOnboardingActive()) {
|
||||||
|
showDownloadToast(browserName, progress.version, "completed");
|
||||||
|
}
|
||||||
setDownloadProgress(null);
|
setDownloadProgress(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -443,7 +458,7 @@ export function useBrowserDownload() {
|
|||||||
showToast({
|
showToast({
|
||||||
id: "geoip-download",
|
id: "geoip-download",
|
||||||
type: "download",
|
type: "download",
|
||||||
title: "Downloading GeoIP database",
|
title: i18n.t("browserDownload.toast.geoipDownloading"),
|
||||||
stage: "downloading",
|
stage: "downloading",
|
||||||
progress: {
|
progress: {
|
||||||
percentage,
|
percentage,
|
||||||
@@ -455,7 +470,7 @@ export function useBrowserDownload() {
|
|||||||
showToast({
|
showToast({
|
||||||
id: "geoip-download",
|
id: "geoip-download",
|
||||||
type: "download",
|
type: "download",
|
||||||
title: "GeoIP database downloaded successfully!",
|
title: i18n.t("browserDownload.toast.geoipDownloaded"),
|
||||||
stage: "completed",
|
stage: "completed",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface DownloadProgress {
|
||||||
|
browser: string;
|
||||||
|
version: string;
|
||||||
|
downloaded_bytes: number;
|
||||||
|
total_bytes: number | null;
|
||||||
|
percentage: number;
|
||||||
|
speed_bytes_per_sec: number;
|
||||||
|
eta_seconds?: number | null;
|
||||||
|
stage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SetupPhase = "downloading" | "extracting" | "ready" | "error";
|
||||||
|
|
||||||
|
export type SetupErrorStage =
|
||||||
|
| "downloading"
|
||||||
|
| "extracting"
|
||||||
|
| "verifying"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
export interface SetupError {
|
||||||
|
stage: SetupErrorStage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The backend emits a real percentage only while downloading; extraction sends
|
||||||
|
// a single "extracting" event with no incremental progress (it takes ~2 min).
|
||||||
|
// So we estimate extraction progress from elapsed time vs. a learned average,
|
||||||
|
// seeded at 2 minutes and refined with the real durations we record.
|
||||||
|
const DEFAULT_EXTRACT_MS = 2 * 60 * 1000;
|
||||||
|
const MAX_SAMPLES = 5; // the 2-min seed + up to 4 most recent real durations
|
||||||
|
|
||||||
|
const storageKey = (browser: string) => `donut.extractDurations.${browser}`;
|
||||||
|
|
||||||
|
function readDurations(browser: string): number[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(storageKey(browser));
|
||||||
|
const arr = raw ? (JSON.parse(raw) as unknown) : null;
|
||||||
|
if (
|
||||||
|
Array.isArray(arr) &&
|
||||||
|
arr.length > 0 &&
|
||||||
|
arr.every((n) => typeof n === "number" && n > 0)
|
||||||
|
) {
|
||||||
|
return arr as number[];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to the seed
|
||||||
|
}
|
||||||
|
return [DEFAULT_EXTRACT_MS];
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordDuration(browser: string, ms: number) {
|
||||||
|
if (!(ms > 0)) return;
|
||||||
|
const current = readDurations(browser);
|
||||||
|
// Keep the 2-min seed as the first value, then the most recent real samples.
|
||||||
|
const samples =
|
||||||
|
current[0] === DEFAULT_EXTRACT_MS ? current.slice(1) : current;
|
||||||
|
const next = [
|
||||||
|
DEFAULT_EXTRACT_MS,
|
||||||
|
...[...samples, ms].slice(-(MAX_SAMPLES - 1)),
|
||||||
|
];
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey(browser), JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
// ignore persistence failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function average(values: number[]): number {
|
||||||
|
return values.reduce((a, b) => a + b, 0) / values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map a backend stage to the error stage we report when something fails.
|
||||||
|
function toErrorStage(stage: string): SetupErrorStage {
|
||||||
|
switch (stage) {
|
||||||
|
case "downloading":
|
||||||
|
return "downloading";
|
||||||
|
case "extracting":
|
||||||
|
return "extracting";
|
||||||
|
case "verifying":
|
||||||
|
return "verifying";
|
||||||
|
default:
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks first-launch setup of a browser: real download progress plus an
|
||||||
|
* estimated extraction progress (no countdown timer, percentages only).
|
||||||
|
* `active` should be true while the owning dialog is open.
|
||||||
|
*/
|
||||||
|
export function useBrowserSetup(browser: string, active: boolean) {
|
||||||
|
const [phase, setPhase] = useState<SetupPhase>("downloading");
|
||||||
|
// Download metrics straight from the latest "downloading" event.
|
||||||
|
const [downloadPercent, setDownloadPercent] = useState(0);
|
||||||
|
const [downloadedBytes, setDownloadedBytes] = useState(0);
|
||||||
|
const [totalBytes, setTotalBytes] = useState<number | null>(null);
|
||||||
|
const [speedBytesPerSec, setSpeedBytesPerSec] = useState(0);
|
||||||
|
const [etaSeconds, setEtaSeconds] = useState<number | null>(null);
|
||||||
|
// Estimated extraction progress (percentages only, capped at 99 until done).
|
||||||
|
const [extractionPercent, setExtractionPercent] = useState(0);
|
||||||
|
const [extractionOvertime, setExtractionOvertime] = useState(false);
|
||||||
|
const [error, setError] = useState<SetupError | null>(null);
|
||||||
|
|
||||||
|
const extractStartRef = useRef<number | null>(null);
|
||||||
|
const estimateRef = useRef(DEFAULT_EXTRACT_MS);
|
||||||
|
// Fallback bookkeeping so a listener that mounts mid-flight (and therefore
|
||||||
|
// misses the single "extracting" event) can still show extraction progress.
|
||||||
|
const sawDownloadingRef = useRef(false);
|
||||||
|
const lastProgressAtRef = useRef<number | null>(null);
|
||||||
|
const lastDownloadPercentRef = useRef(0);
|
||||||
|
// The last non-terminal stage we observed, used to label an error.
|
||||||
|
const lastStageRef = useRef<string>("downloading");
|
||||||
|
// Set once a terminal state (ready/error) is reached. Stops the tick so the
|
||||||
|
// mid-flight extraction fallback can't re-arm and fight the readiness poll
|
||||||
|
// (which would oscillate "ready" ↔ "Almost finished" forever).
|
||||||
|
const doneRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) {
|
||||||
|
// Fully reset when the owning dialog closes.
|
||||||
|
setPhase("downloading");
|
||||||
|
setDownloadPercent(0);
|
||||||
|
setDownloadedBytes(0);
|
||||||
|
setTotalBytes(null);
|
||||||
|
setSpeedBytesPerSec(0);
|
||||||
|
setEtaSeconds(null);
|
||||||
|
setExtractionPercent(0);
|
||||||
|
setExtractionOvertime(false);
|
||||||
|
setError(null);
|
||||||
|
extractStartRef.current = null;
|
||||||
|
sawDownloadingRef.current = false;
|
||||||
|
lastProgressAtRef.current = null;
|
||||||
|
lastDownloadPercentRef.current = 0;
|
||||||
|
lastStageRef.current = "downloading";
|
||||||
|
doneRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive = true;
|
||||||
|
estimateRef.current = average(readDurations(browser));
|
||||||
|
extractStartRef.current = null;
|
||||||
|
sawDownloadingRef.current = false;
|
||||||
|
lastProgressAtRef.current = null;
|
||||||
|
lastDownloadPercentRef.current = 0;
|
||||||
|
lastStageRef.current = "downloading";
|
||||||
|
doneRef.current = false;
|
||||||
|
|
||||||
|
const finishExtraction = () => {
|
||||||
|
if (extractStartRef.current != null) {
|
||||||
|
recordDuration(browser, Date.now() - extractStartRef.current);
|
||||||
|
extractStartRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unlistenPromise = listen<DownloadProgress>(
|
||||||
|
"download-progress",
|
||||||
|
(event) => {
|
||||||
|
if (!alive) return;
|
||||||
|
const p = event.payload;
|
||||||
|
if (p.browser !== browser) return;
|
||||||
|
switch (p.stage) {
|
||||||
|
case "downloading":
|
||||||
|
lastStageRef.current = "downloading";
|
||||||
|
sawDownloadingRef.current = true;
|
||||||
|
lastProgressAtRef.current = Date.now();
|
||||||
|
lastDownloadPercentRef.current = p.percentage;
|
||||||
|
setPhase("downloading");
|
||||||
|
setDownloadPercent(Math.round(p.percentage));
|
||||||
|
setDownloadedBytes(p.downloaded_bytes);
|
||||||
|
setTotalBytes(p.total_bytes ?? null);
|
||||||
|
setSpeedBytesPerSec(p.speed_bytes_per_sec);
|
||||||
|
setEtaSeconds(p.eta_seconds ?? null);
|
||||||
|
break;
|
||||||
|
case "extracting":
|
||||||
|
lastStageRef.current = "extracting";
|
||||||
|
if (extractStartRef.current == null) {
|
||||||
|
extractStartRef.current = Date.now();
|
||||||
|
}
|
||||||
|
lastProgressAtRef.current = Date.now();
|
||||||
|
setPhase("extracting");
|
||||||
|
break;
|
||||||
|
case "verifying":
|
||||||
|
lastStageRef.current = "verifying";
|
||||||
|
finishExtraction();
|
||||||
|
// Verification is the tail of extraction; keep the bar near full
|
||||||
|
// but don't claim "ready" until "completed" arrives.
|
||||||
|
setPhase("extracting");
|
||||||
|
setExtractionPercent(99);
|
||||||
|
break;
|
||||||
|
case "completed":
|
||||||
|
doneRef.current = true;
|
||||||
|
finishExtraction();
|
||||||
|
setPhase("ready");
|
||||||
|
setExtractionPercent(100);
|
||||||
|
setExtractionOvertime(false);
|
||||||
|
setError(null);
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
doneRef.current = true;
|
||||||
|
finishExtraction();
|
||||||
|
setPhase("error");
|
||||||
|
setError({ stage: toErrorStage(lastStageRef.current) });
|
||||||
|
break;
|
||||||
|
case "cancelled":
|
||||||
|
// Treat a cancellation like an error so the dialog can offer retry.
|
||||||
|
doneRef.current = true;
|
||||||
|
finishExtraction();
|
||||||
|
setPhase("error");
|
||||||
|
setError({ stage: "other" });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Authoritative completion signal: poll the registry. The "completed" event
|
||||||
|
// is only a fast-path — we never rely on it alone. This MUST be a recurring
|
||||||
|
// interval rather than a one-shot loop: independent firings mean a single
|
||||||
|
// invoke that stalls during heavy extraction can't kill detection, it keeps
|
||||||
|
// confirming readiness so retry() re-detects an already-downloaded browser
|
||||||
|
// without restarting the effect, and it covers a browser downloaded before
|
||||||
|
// this hook mounted. setPhase("ready") is idempotent, so re-confirming is
|
||||||
|
// free (React bails out when state is unchanged).
|
||||||
|
let checkingReady = false;
|
||||||
|
const checkReady = async () => {
|
||||||
|
if (!alive || checkingReady) return;
|
||||||
|
checkingReady = true;
|
||||||
|
try {
|
||||||
|
const versions = await invoke<string[]>(
|
||||||
|
"get_downloaded_browser_versions",
|
||||||
|
{ browserStr: browser },
|
||||||
|
);
|
||||||
|
if (alive && versions.length > 0) {
|
||||||
|
doneRef.current = true;
|
||||||
|
finishExtraction();
|
||||||
|
setPhase("ready");
|
||||||
|
setExtractionPercent(100);
|
||||||
|
setExtractionOvertime(false);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to check browser download status:", err);
|
||||||
|
} finally {
|
||||||
|
checkingReady = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void checkReady();
|
||||||
|
const readyPoll = setInterval(() => {
|
||||||
|
void checkReady();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Drive the estimated extraction percentage while extracting.
|
||||||
|
const tick = setInterval(() => {
|
||||||
|
if (!alive || doneRef.current) return;
|
||||||
|
// If the download visibly finished but we never saw the (single)
|
||||||
|
// "extracting" event, start estimating extraction anyway — anchored to
|
||||||
|
// the last download event, which is roughly when extraction began.
|
||||||
|
if (
|
||||||
|
extractStartRef.current == null &&
|
||||||
|
sawDownloadingRef.current &&
|
||||||
|
lastDownloadPercentRef.current >= 99 &&
|
||||||
|
lastProgressAtRef.current != null &&
|
||||||
|
Date.now() - lastProgressAtRef.current > 1200
|
||||||
|
) {
|
||||||
|
extractStartRef.current = lastProgressAtRef.current;
|
||||||
|
lastStageRef.current = "extracting";
|
||||||
|
setPhase("extracting");
|
||||||
|
}
|
||||||
|
if (extractStartRef.current == null) return;
|
||||||
|
const elapsed = Date.now() - extractStartRef.current;
|
||||||
|
const est = estimateRef.current || DEFAULT_EXTRACT_MS;
|
||||||
|
if (elapsed >= est) {
|
||||||
|
// We've blown past the estimate — hold at 99 and flag overtime so the
|
||||||
|
// dialog can show "Almost finished" instead of a stalled number.
|
||||||
|
setExtractionPercent(99);
|
||||||
|
setExtractionOvertime(true);
|
||||||
|
} else {
|
||||||
|
setExtractionPercent(Math.min(99, Math.round((elapsed / est) * 100)));
|
||||||
|
setExtractionOvertime(false);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(tick);
|
||||||
|
clearInterval(readyPoll);
|
||||||
|
void unlistenPromise.then((u) => {
|
||||||
|
u();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [browser, active]);
|
||||||
|
|
||||||
|
const retry = useCallback(() => {
|
||||||
|
// Reset visible state and the bookkeeping refs, then kick off the download
|
||||||
|
// again. The effect's event listener and registry poll stay alive the whole
|
||||||
|
// time the dialog is open, so they pick up the fresh attempt — no need to
|
||||||
|
// restart the effect.
|
||||||
|
setPhase("downloading");
|
||||||
|
setDownloadPercent(0);
|
||||||
|
setDownloadedBytes(0);
|
||||||
|
setTotalBytes(null);
|
||||||
|
setSpeedBytesPerSec(0);
|
||||||
|
setEtaSeconds(null);
|
||||||
|
setExtractionPercent(0);
|
||||||
|
setExtractionOvertime(false);
|
||||||
|
setError(null);
|
||||||
|
extractStartRef.current = null;
|
||||||
|
sawDownloadingRef.current = false;
|
||||||
|
lastProgressAtRef.current = null;
|
||||||
|
lastDownloadPercentRef.current = 0;
|
||||||
|
lastStageRef.current = "downloading";
|
||||||
|
doneRef.current = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await invoke("ensure_active_browsers_downloaded");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to re-trigger browser setup:", err);
|
||||||
|
setPhase("error");
|
||||||
|
setError({ stage: "other" });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
downloadPercent,
|
||||||
|
downloadedBytes,
|
||||||
|
totalBytes,
|
||||||
|
speedBytesPerSec,
|
||||||
|
etaSeconds,
|
||||||
|
extractionPercent,
|
||||||
|
extractionOvertime,
|
||||||
|
ready: phase === "ready",
|
||||||
|
error,
|
||||||
|
retry,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ const loadMacOSPermissions = async () => {
|
|||||||
export type PermissionType = "microphone" | "camera";
|
export type PermissionType = "microphone" | "camera";
|
||||||
|
|
||||||
interface UsePermissionsReturn {
|
interface UsePermissionsReturn {
|
||||||
requestPermission: (type: PermissionType) => Promise<void>;
|
requestPermission: (type: PermissionType) => Promise<boolean>;
|
||||||
isMicrophoneAccessGranted: boolean;
|
isMicrophoneAccessGranted: boolean;
|
||||||
isCameraAccessGranted: boolean;
|
isCameraAccessGranted: boolean;
|
||||||
isInitialized: boolean;
|
isInitialized: boolean;
|
||||||
@@ -68,51 +68,44 @@ export function usePermissions(): UsePermissionsReturn {
|
|||||||
|
|
||||||
// Request permission
|
// Request permission
|
||||||
const requestPermission = useCallback(
|
const requestPermission = useCallback(
|
||||||
async (type: PermissionType): Promise<void> => {
|
async (type: PermissionType): Promise<boolean> => {
|
||||||
if (!currentPlatform || currentPlatform !== "macos") return;
|
// Non-macOS platforms do not require this permission gate.
|
||||||
|
if (!currentPlatform || currentPlatform !== "macos") return true;
|
||||||
|
|
||||||
// macOS - use the permissions API
|
// macOS - use the permissions API
|
||||||
try {
|
try {
|
||||||
const permissions = await loadMacOSPermissions();
|
const permissions = await loadMacOSPermissions();
|
||||||
if (!permissions) return;
|
if (!permissions) return false;
|
||||||
|
|
||||||
|
const readPermission = async () => {
|
||||||
|
const granted =
|
||||||
|
type === "microphone"
|
||||||
|
? await permissions.checkMicrophonePermission()
|
||||||
|
: await permissions.checkCameraPermission();
|
||||||
|
if (type === "microphone") {
|
||||||
|
setIsMicrophoneAccessGranted(granted);
|
||||||
|
} else {
|
||||||
|
setIsCameraAccessGranted(granted);
|
||||||
|
}
|
||||||
|
return granted;
|
||||||
|
};
|
||||||
|
|
||||||
if (type === "microphone") {
|
if (type === "microphone") {
|
||||||
await permissions.requestMicrophonePermission();
|
await permissions.requestMicrophonePermission();
|
||||||
|
} else {
|
||||||
// Poll for permission status change
|
|
||||||
const pollMicPermission = async () => {
|
|
||||||
const granted = await permissions.checkMicrophonePermission();
|
|
||||||
setIsMicrophoneAccessGranted(granted);
|
|
||||||
|
|
||||||
if (!granted) {
|
|
||||||
setTimeout(() => {
|
|
||||||
void pollMicPermission();
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await pollMicPermission();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "camera") {
|
|
||||||
await permissions.requestCameraPermission();
|
await permissions.requestCameraPermission();
|
||||||
|
|
||||||
// Poll for permission status change
|
|
||||||
const pollCamPermission = async () => {
|
|
||||||
const granted = await permissions.checkCameraPermission();
|
|
||||||
setIsCameraAccessGranted(granted);
|
|
||||||
|
|
||||||
if (!granted) {
|
|
||||||
setTimeout(() => {
|
|
||||||
void pollCamPermission();
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await pollCamPermission();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||||
|
const granted = await readPermission();
|
||||||
|
if (granted) return true;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
return readPermission();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to request ${type} permission on macOS:`, error);
|
console.error(`Failed to request ${type} permission on macOS:`, error);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentPlatform],
|
[currentPlatform],
|
||||||
|
|||||||
@@ -62,8 +62,12 @@ export function useUpdateNotifications(
|
|||||||
showToast({
|
showToast({
|
||||||
id: `auto-update-started-${browser}-${newVersion}`,
|
id: `auto-update-started-${browser}-${newVersion}`,
|
||||||
type: "loading",
|
type: "loading",
|
||||||
title: `${browserDisplayName} update started`,
|
title: i18n.t("versionUpdater.toast.updateStarted", {
|
||||||
description: `Version ${newVersion} download will begin shortly. Browser launch is disabled until update completes.`,
|
browser: browserDisplayName,
|
||||||
|
}),
|
||||||
|
description: i18n.t("versionUpdater.toast.updateStartedDescription", {
|
||||||
|
version: newVersion,
|
||||||
|
}),
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,8 +87,11 @@ export function useUpdateNotifications(
|
|||||||
showToast({
|
showToast({
|
||||||
id: `auto-update-skip-download-${browser}-${newVersion}`,
|
id: `auto-update-skip-download-${browser}-${newVersion}`,
|
||||||
type: "success",
|
type: "success",
|
||||||
title: `${browserDisplayName} ${newVersion} already available`,
|
title: i18n.t("versionUpdater.toast.alreadyAvailable", {
|
||||||
description: "Updating profile configurations...",
|
browser: browserDisplayName,
|
||||||
|
version: newVersion,
|
||||||
|
}),
|
||||||
|
description: i18n.t("versionUpdater.toast.updatingProfiles"),
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -92,8 +99,11 @@ export function useUpdateNotifications(
|
|||||||
showToast({
|
showToast({
|
||||||
id: `auto-update-download-starting-${browser}-${newVersion}`,
|
id: `auto-update-download-starting-${browser}-${newVersion}`,
|
||||||
type: "loading",
|
type: "loading",
|
||||||
title: `Starting ${browserDisplayName} ${newVersion} download`,
|
title: i18n.t("versionUpdater.toast.downloadStarting", {
|
||||||
description: "Download progress will be shown below...",
|
browser: browserDisplayName,
|
||||||
|
version: newVersion,
|
||||||
|
}),
|
||||||
|
description: i18n.t("versionUpdater.toast.downloadProgressBelow"),
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,24 +125,36 @@ export function useUpdateNotifications(
|
|||||||
|
|
||||||
// Show success message based on whether profiles were updated
|
// Show success message based on whether profiles were updated
|
||||||
if (updatedProfiles.length > 0) {
|
if (updatedProfiles.length > 0) {
|
||||||
const profileText =
|
const description =
|
||||||
updatedProfiles.length === 1
|
updatedProfiles.length === 1
|
||||||
? `Profile "${updatedProfiles[0]}" has been updated`
|
? i18n.t("versionUpdater.toast.singleProfileUpdated", {
|
||||||
: `${updatedProfiles.length} profiles have been updated`;
|
name: updatedProfiles[0],
|
||||||
|
version: newVersion,
|
||||||
|
})
|
||||||
|
: i18n.t("versionUpdater.toast.multipleProfilesUpdated", {
|
||||||
|
count: updatedProfiles.length,
|
||||||
|
version: newVersion,
|
||||||
|
});
|
||||||
|
|
||||||
showToast({
|
showToast({
|
||||||
id: `auto-update-success-${browser}-${newVersion}`,
|
id: `auto-update-success-${browser}-${newVersion}`,
|
||||||
type: "success",
|
type: "success",
|
||||||
title: `${browserDisplayName} update completed`,
|
title: i18n.t("versionUpdater.toast.updateCompleted", {
|
||||||
description: `${profileText} to version ${newVersion}. You can now launch your browsers with the latest version.`,
|
browser: browserDisplayName,
|
||||||
|
}),
|
||||||
|
description,
|
||||||
duration: 6000,
|
duration: 6000,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
showToast({
|
showToast({
|
||||||
id: `auto-update-success-${browser}-${newVersion}`,
|
id: `auto-update-success-${browser}-${newVersion}`,
|
||||||
type: "success",
|
type: "success",
|
||||||
title: `${browserDisplayName} update completed`,
|
title: i18n.t("versionUpdater.toast.updateCompleted", {
|
||||||
description: `Version ${newVersion} is now available. Running profiles will use the new version when restarted.`,
|
browser: browserDisplayName,
|
||||||
|
}),
|
||||||
|
description: i18n.t("versionUpdater.toast.versionAvailable", {
|
||||||
|
version: newVersion,
|
||||||
|
}),
|
||||||
duration: 6000,
|
duration: 6000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,13 @@ export function useVersionUpdater() {
|
|||||||
try {
|
try {
|
||||||
// Show auto-update start notification
|
// Show auto-update start notification
|
||||||
showAutoUpdateToast(browserDisplayName, new_version, {
|
showAutoUpdateToast(browserDisplayName, new_version, {
|
||||||
description: `Downloading ${browserDisplayName} ${new_version} automatically. Progress will be shown below.`,
|
description: i18n.t(
|
||||||
|
"versionUpdater.toast.autoDownloadStarted",
|
||||||
|
{
|
||||||
|
browser: browserDisplayName,
|
||||||
|
version: new_version,
|
||||||
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dismiss the update notification in the backend
|
// Dismiss the update notification in the backend
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import ja from "./locales/ja.json";
|
|||||||
import ko from "./locales/ko.json";
|
import ko from "./locales/ko.json";
|
||||||
import pt from "./locales/pt.json";
|
import pt from "./locales/pt.json";
|
||||||
import ru from "./locales/ru.json";
|
import ru from "./locales/ru.json";
|
||||||
|
import vi from "./locales/vi.json";
|
||||||
import zh from "./locales/zh.json";
|
import zh from "./locales/zh.json";
|
||||||
|
|
||||||
export const SUPPORTED_LANGUAGES = [
|
export const SUPPORTED_LANGUAGES = [
|
||||||
@@ -19,6 +20,7 @@ export const SUPPORTED_LANGUAGES = [
|
|||||||
{ code: "ja", name: "Japanese", nativeName: "日本語" },
|
{ code: "ja", name: "Japanese", nativeName: "日本語" },
|
||||||
{ code: "ko", name: "Korean", nativeName: "한국어" },
|
{ code: "ko", name: "Korean", nativeName: "한국어" },
|
||||||
{ code: "ru", name: "Russian", nativeName: "Русский" },
|
{ code: "ru", name: "Russian", nativeName: "Русский" },
|
||||||
|
{ code: "vi", name: "Vietnamese", nativeName: "Tiếng Việt" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]["code"];
|
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]["code"];
|
||||||
@@ -36,6 +38,7 @@ export const LANGUAGE_FALLBACKS: Record<string, string[]> = {
|
|||||||
"es-ES": ["es", "en"],
|
"es-ES": ["es", "en"],
|
||||||
"fr-CA": ["fr", "en"],
|
"fr-CA": ["fr", "en"],
|
||||||
"fr-FR": ["fr", "en"],
|
"fr-FR": ["fr", "en"],
|
||||||
|
"vi-VN": ["vi", "en"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getLanguageWithFallback(systemLocale: string): string {
|
export function getLanguageWithFallback(systemLocale: string): string {
|
||||||
@@ -65,6 +68,7 @@ const resources = {
|
|||||||
ja: { translation: ja },
|
ja: { translation: ja },
|
||||||
ko: { translation: ko },
|
ko: { translation: ko },
|
||||||
ru: { translation: ru },
|
ru: { translation: ru },
|
||||||
|
vi: { translation: vi },
|
||||||
};
|
};
|
||||||
|
|
||||||
i18n.use(initReactI18next).init({
|
i18n.use(initReactI18next).init({
|
||||||
|
|||||||
+126
-21
@@ -33,7 +33,8 @@
|
|||||||
"minimize": "Minimize",
|
"minimize": "Minimize",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"saved": "Saved",
|
"saved": "Saved",
|
||||||
"copied": "Copied"
|
"copied": "Copied",
|
||||||
|
"learnMore": "Learn more"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
@@ -99,6 +100,9 @@
|
|||||||
"srOnly": {
|
"srOnly": {
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copied": "Copied"
|
"copied": "Copied"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"example": "e.g., {{value}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -340,7 +344,8 @@
|
|||||||
"downloading": "Downloading {{browser}} version ({{version}})...",
|
"downloading": "Downloading {{browser}} version ({{version}})...",
|
||||||
"latestNeedsDownload": "Latest version ({{version}}) needs to be downloaded",
|
"latestNeedsDownload": "Latest version ({{version}}) needs to be downloaded",
|
||||||
"latestAvailable": "Latest version ({{version}}) is available",
|
"latestAvailable": "Latest version ({{version}}) is available",
|
||||||
"latestDownloading": "Downloading version ({{version}})..."
|
"latestDownloading": "Downloading version ({{version}})...",
|
||||||
|
"upgradeAvailable": "A newer version ({{version}}) of {{browser}} is available."
|
||||||
},
|
},
|
||||||
"chromiumLabel": "Chromium",
|
"chromiumLabel": "Chromium",
|
||||||
"chromiumSubtitle": "Powered by Wayfern",
|
"chromiumSubtitle": "Powered by Wayfern",
|
||||||
@@ -351,7 +356,9 @@
|
|||||||
"passwordProtect": {
|
"passwordProtect": {
|
||||||
"label": "Password protect this profile",
|
"label": "Password protect this profile",
|
||||||
"description": "Encrypts the on-disk profile data. Required to launch."
|
"description": "Encrypts the on-disk profile data. Required to launch."
|
||||||
}
|
},
|
||||||
|
"downloadingSubtitle": "Downloading…",
|
||||||
|
"browsersDownloading": "Browsers are still downloading. Profile creation will be available once a download finishes."
|
||||||
},
|
},
|
||||||
"deleteDialog": {
|
"deleteDialog": {
|
||||||
"title": "Delete Profile",
|
"title": "Delete Profile",
|
||||||
@@ -1188,7 +1195,9 @@
|
|||||||
"cannotWhileRunning": "Stop the profile before changing its password."
|
"cannotWhileRunning": "Stop the profile before changing its password."
|
||||||
},
|
},
|
||||||
"fingerprint": {
|
"fingerprint": {
|
||||||
"notSupported": "Fingerprint editing is only available for Camoufox and Wayfern profiles."
|
"notSupported": "Fingerprint editing is only available for Camoufox and Wayfern profiles.",
|
||||||
|
"lockedTitle": "Viewing & editing the fingerprint is a Pro feature",
|
||||||
|
"lockedDescription": "Fingerprint protection is included on every plan. Viewing and editing a profile's fingerprint values is what requires an active paid plan."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extensions": {
|
"extensions": {
|
||||||
@@ -1524,17 +1533,6 @@
|
|||||||
"creatingButton": "Creating...",
|
"creatingButton": "Creating...",
|
||||||
"createButton": "Create"
|
"createButton": "Create"
|
||||||
},
|
},
|
||||||
"launchOnLogin": {
|
|
||||||
"title": "Enable Launch on Login?",
|
|
||||||
"description": "Running in the background helps keep your proxies and browsers alive.",
|
|
||||||
"declineButton": "Don't Ask Again",
|
|
||||||
"declining": "...",
|
|
||||||
"enableButton": "Enable",
|
|
||||||
"enableSuccess": "Launch on login enabled",
|
|
||||||
"enableFailed": "Failed to enable launch on login",
|
|
||||||
"declineFailed": "Failed to save preference",
|
|
||||||
"tryAgain": "Please try again"
|
|
||||||
},
|
|
||||||
"wayfernTerms": {
|
"wayfernTerms": {
|
||||||
"title": "Wayfern Terms and Conditions",
|
"title": "Wayfern Terms and Conditions",
|
||||||
"description": "Before using Donut Browser, you must read and agree to Wayfern's Terms and Conditions.",
|
"description": "Before using Donut Browser, you must read and agree to Wayfern's Terms and Conditions.",
|
||||||
@@ -1680,7 +1678,8 @@
|
|||||||
"viewRelease": "View Release",
|
"viewRelease": "View Release",
|
||||||
"later": "Later",
|
"later": "Later",
|
||||||
"uploading": "Uploading",
|
"uploading": "Uploading",
|
||||||
"downloading": "Downloading"
|
"downloading": "Downloading",
|
||||||
|
"startingUpdate": "Starting update..."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"browserDownload": {
|
"browserDownload": {
|
||||||
@@ -1694,7 +1693,9 @@
|
|||||||
"extractionFailedDescription": "The corrupt file was deleted. It will be re-downloaded on next attempt.",
|
"extractionFailedDescription": "The corrupt file was deleted. It will be re-downloaded on next attempt.",
|
||||||
"extracting": "Extracting browser files... Please do not close the app.",
|
"extracting": "Extracting browser files... Please do not close the app.",
|
||||||
"verifying": "Verifying browser files...",
|
"verifying": "Verifying browser files...",
|
||||||
"downloadingRolling": "Downloading rolling release build..."
|
"downloadingRolling": "Downloading rolling release build...",
|
||||||
|
"geoipDownloading": "Downloading GeoIP database",
|
||||||
|
"geoipDownloaded": "GeoIP database downloaded successfully!"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"versionUpdater": {
|
"versionUpdater": {
|
||||||
@@ -1712,7 +1713,12 @@
|
|||||||
"updateSuccessDescription": "Found {{newVersions}} new versions across {{successfulUpdates}} browsers. Auto-downloads will start shortly.",
|
"updateSuccessDescription": "Found {{newVersions}} new versions across {{successfulUpdates}} browsers. Auto-downloads will start shortly.",
|
||||||
"upToDate": "No new browser versions found",
|
"upToDate": "No new browser versions found",
|
||||||
"upToDateDescription": "All browser versions are up to date",
|
"upToDateDescription": "All browser versions are up to date",
|
||||||
"updateAllFailed": "Failed to update browser versions"
|
"updateAllFailed": "Failed to update browser versions",
|
||||||
|
"updateStarted": "{{browser}} update started",
|
||||||
|
"updateStartedDescription": "Version {{version}} download will begin shortly. Browser launch is disabled until update completes.",
|
||||||
|
"downloadStarting": "Starting {{browser}} {{version}} download",
|
||||||
|
"downloadProgressBelow": "Download progress will be shown below...",
|
||||||
|
"autoDownloadStarted": "Downloading {{browser}} {{version}} automatically. Progress will be shown below."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profilePassword": {
|
"profilePassword": {
|
||||||
@@ -1800,7 +1806,11 @@
|
|||||||
"invalidLaunchHookUrl": "Invalid launch hook URL. Use a full http:// or https:// URL.",
|
"invalidLaunchHookUrl": "Invalid launch hook URL. Use a full http:// or https:// URL.",
|
||||||
"cookieDbLocked": "Could not read cookies — the database is locked. Close the browser and try again.",
|
"cookieDbLocked": "Could not read cookies — the database is locked. Close the browser and try again.",
|
||||||
"cookieDbUnavailable": "Could not read cookies — the cookie store is unavailable.",
|
"cookieDbUnavailable": "Could not read cookies — the cookie store is unavailable.",
|
||||||
"selfHostedRequiresLogout": "Sign out of your Donut account before configuring a self-hosted server."
|
"selfHostedRequiresLogout": "Sign out of your Donut account before configuring a self-hosted server.",
|
||||||
|
"fingerprintRequiresPro": "Viewing or editing the fingerprint requires an active paid plan. Protection is included on all plans.",
|
||||||
|
"proxyNotWorking": "The selected proxy isn't working, so the profile wasn't created.",
|
||||||
|
"proxyPaymentRequired": "The selected proxy requires payment (402) — its subscription may have expired — so the profile wasn't created.",
|
||||||
|
"vpnNotWorking": "The selected VPN isn't working, so the profile wasn't created."
|
||||||
},
|
},
|
||||||
"rail": {
|
"rail": {
|
||||||
"profiles": "Profiles",
|
"profiles": "Profiles",
|
||||||
@@ -1866,7 +1876,8 @@
|
|||||||
"plan": "Plan",
|
"plan": "Plan",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"teamRole": "Team role",
|
"teamRole": "Team role",
|
||||||
"period": "Billing period"
|
"period": "Billing period",
|
||||||
|
"device": "Device"
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
@@ -1880,7 +1891,10 @@
|
|||||||
"statusUnknown": "Untested",
|
"statusUnknown": "Untested",
|
||||||
"testConnection": "Test connection",
|
"testConnection": "Test connection",
|
||||||
"disconnect": "Disconnect"
|
"disconnect": "Disconnect"
|
||||||
}
|
},
|
||||||
|
"deviceOrdinal": "{{ordinal}} of {{count}}",
|
||||||
|
"automationPrimaryOnly": "Browser automation runs only on your primary device (Device 1). Sign out there to use it here.",
|
||||||
|
"automationActiveHere": "Browser automation is active on this device."
|
||||||
},
|
},
|
||||||
"shortcutsPage": {
|
"shortcutsPage": {
|
||||||
"title": "Keyboard shortcuts",
|
"title": "Keyboard shortcuts",
|
||||||
@@ -1912,5 +1926,96 @@
|
|||||||
"goIntegrations": "Go to Integrations",
|
"goIntegrations": "Go to Integrations",
|
||||||
"goAccount": "Go to Account",
|
"goAccount": "Go to Account",
|
||||||
"goSettings": "Go to Settings"
|
"goSettings": "Go to Settings"
|
||||||
|
},
|
||||||
|
"closeConfirm": {
|
||||||
|
"title": "Close Donut Browser?",
|
||||||
|
"description": "Would you like to send the app to the system tray or quit?",
|
||||||
|
"minimize": "Minimize to Tray",
|
||||||
|
"quit": "Quit"
|
||||||
|
},
|
||||||
|
"tray": {
|
||||||
|
"show": "Show Donut Browser",
|
||||||
|
"quit": "Quit"
|
||||||
|
},
|
||||||
|
"browserSupport": {
|
||||||
|
"endingSoonTitle": "Browser support ending soon",
|
||||||
|
"endingSoonDescription": "Support for the following profiles will be removed on March 15, 2026: {{profiles}}. Please migrate to Wayfern or Camoufox profiles."
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"steps": {
|
||||||
|
"createProfile": {
|
||||||
|
"title": "Create your first profile",
|
||||||
|
"content": "Click here to create your first profile. Pick Wayfern as the browser — the recommended, fingerprint-protected Chromium."
|
||||||
|
},
|
||||||
|
"dnsBlocking": {
|
||||||
|
"title": "DNS blocking",
|
||||||
|
"content": "Use this dropdown to set a DNS blocklist level for the profile — it blocks ads, trackers, and malware at the network level. Higher levels block more."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"skip": "Skip",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"finish": "Finish"
|
||||||
|
},
|
||||||
|
"thankYou": {
|
||||||
|
"title": "Thank you for choosing Donut Browser",
|
||||||
|
"body": "Hopefully it helps make your browsing more private — every identity kept its own, and nothing leaving your machine. Enjoy.",
|
||||||
|
"cta": "Start browsing"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"welcome": {
|
||||||
|
"title": "Welcome to Donut Browser",
|
||||||
|
"tagline": "An open-source anti-detect browser for managing many identities at once.",
|
||||||
|
"skip": "Skip",
|
||||||
|
"next": "Next",
|
||||||
|
"permissions": {
|
||||||
|
"title": "Allow microphone & camera",
|
||||||
|
"desc": "Grant access so sites that need a mic or camera work inside your browser profiles. macOS asks once; each site still asks you individually.",
|
||||||
|
"skip": "Not now",
|
||||||
|
"grant": "Allow access",
|
||||||
|
"requesting": "Requesting…"
|
||||||
|
},
|
||||||
|
"ready": {
|
||||||
|
"title": "Setting things up",
|
||||||
|
"descDownloading": "Downloading your first browser (Wayfern). This one-time setup runs in the background — hang tight.",
|
||||||
|
"descReady": "Your browser is ready. Let's create your first profile.",
|
||||||
|
"cta": "Create my first profile",
|
||||||
|
"downloading": "Downloading…",
|
||||||
|
"extracting": "Extracting…",
|
||||||
|
"stats": "{{downloaded}} of {{total}}",
|
||||||
|
"speed": "{{speed}}/s",
|
||||||
|
"timeLeft": "{{time}} left",
|
||||||
|
"descExtracting": "Extracting your browser. This one-time setup runs in the background — hang tight.",
|
||||||
|
"almostFinished": "Almost finished…",
|
||||||
|
"errorTitle": "Setup failed",
|
||||||
|
"errorDownload": "{{browser}} couldn't be downloaded. Check your connection and try again.",
|
||||||
|
"errorExtraction": "{{browser}} couldn't be extracted. Please try again.",
|
||||||
|
"errorGeneric": "Something went wrong while setting up {{browser}}. Please try again.",
|
||||||
|
"retry": "Try again"
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"title": "Features",
|
||||||
|
"items": {
|
||||||
|
"setDefault": "Set as Default Browser",
|
||||||
|
"proxy": "Proxy Support (HTTP/SOCKS5)",
|
||||||
|
"vpn": "VPN Support (WireGuard)",
|
||||||
|
"profiles": "Unlimited Local Profiles",
|
||||||
|
"api": "Profile Management API & MCP",
|
||||||
|
"openSource": "Open Source",
|
||||||
|
"groups": "Profile Groups",
|
||||||
|
"cookies": "Cookie Import & Export"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"title": "Licensing",
|
||||||
|
"body": "Donut Browser is open source and free to use.",
|
||||||
|
"agree": "I understand",
|
||||||
|
"personalTitle": "Personal use",
|
||||||
|
"personalDesc": "Free forever.",
|
||||||
|
"commercialTitle": "Commercial use",
|
||||||
|
"trialBadge": "2 weeks free",
|
||||||
|
"commercialDesc": "Free for a 2-week evaluation. After that, a paid plan keeps the project maintained and thriving."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+126
-21
@@ -33,7 +33,8 @@
|
|||||||
"minimize": "Minimizar",
|
"minimize": "Minimizar",
|
||||||
"saving": "Guardando…",
|
"saving": "Guardando…",
|
||||||
"saved": "Guardado",
|
"saved": "Guardado",
|
||||||
"copied": "Copiado"
|
"copied": "Copiado",
|
||||||
|
"learnMore": "Más información"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Activo",
|
"active": "Activo",
|
||||||
@@ -99,6 +100,9 @@
|
|||||||
"srOnly": {
|
"srOnly": {
|
||||||
"copy": "Copiar",
|
"copy": "Copiar",
|
||||||
"copied": "Copiado"
|
"copied": "Copiado"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"example": "p. ej., {{value}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -340,7 +344,8 @@
|
|||||||
"downloading": "Descargando versión de {{browser}} ({{version}})...",
|
"downloading": "Descargando versión de {{browser}} ({{version}})...",
|
||||||
"latestNeedsDownload": "La última versión ({{version}}) necesita ser descargada",
|
"latestNeedsDownload": "La última versión ({{version}}) necesita ser descargada",
|
||||||
"latestAvailable": "La última versión ({{version}}) está disponible",
|
"latestAvailable": "La última versión ({{version}}) está disponible",
|
||||||
"latestDownloading": "Descargando versión ({{version}})..."
|
"latestDownloading": "Descargando versión ({{version}})...",
|
||||||
|
"upgradeAvailable": "Hay una versión más reciente ({{version}}) de {{browser}} disponible."
|
||||||
},
|
},
|
||||||
"chromiumLabel": "Chromium",
|
"chromiumLabel": "Chromium",
|
||||||
"chromiumSubtitle": "Impulsado por Wayfern",
|
"chromiumSubtitle": "Impulsado por Wayfern",
|
||||||
@@ -351,7 +356,9 @@
|
|||||||
"passwordProtect": {
|
"passwordProtect": {
|
||||||
"label": "Proteger este perfil con contraseña",
|
"label": "Proteger este perfil con contraseña",
|
||||||
"description": "Cifra los datos del perfil en disco. Necesario para abrirlo."
|
"description": "Cifra los datos del perfil en disco. Necesario para abrirlo."
|
||||||
}
|
},
|
||||||
|
"downloadingSubtitle": "Descargando…",
|
||||||
|
"browsersDownloading": "Los navegadores aún se están descargando. La creación de perfiles estará disponible cuando termine una descarga."
|
||||||
},
|
},
|
||||||
"deleteDialog": {
|
"deleteDialog": {
|
||||||
"title": "Eliminar Perfil",
|
"title": "Eliminar Perfil",
|
||||||
@@ -1188,7 +1195,9 @@
|
|||||||
"cannotWhileRunning": "Detén el perfil antes de cambiar su contraseña."
|
"cannotWhileRunning": "Detén el perfil antes de cambiar su contraseña."
|
||||||
},
|
},
|
||||||
"fingerprint": {
|
"fingerprint": {
|
||||||
"notSupported": "La edición de huellas digitales solo está disponible para perfiles Camoufox y Wayfern."
|
"notSupported": "La edición de huellas digitales solo está disponible para perfiles Camoufox y Wayfern.",
|
||||||
|
"lockedTitle": "Ver y editar la huella digital es una función Pro",
|
||||||
|
"lockedDescription": "La protección de huella digital está incluida en todos los planes. Ver y editar los valores de la huella digital de un perfil es lo que requiere un plan de pago activo."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extensions": {
|
"extensions": {
|
||||||
@@ -1524,17 +1533,6 @@
|
|||||||
"creatingButton": "Creando...",
|
"creatingButton": "Creando...",
|
||||||
"createButton": "Crear"
|
"createButton": "Crear"
|
||||||
},
|
},
|
||||||
"launchOnLogin": {
|
|
||||||
"title": "¿Activar inicio al iniciar sesión?",
|
|
||||||
"description": "Ejecutarse en segundo plano ayuda a mantener vivos los proxies y navegadores.",
|
|
||||||
"declineButton": "No volver a preguntar",
|
|
||||||
"declining": "...",
|
|
||||||
"enableButton": "Activar",
|
|
||||||
"enableSuccess": "Inicio al iniciar sesión activado",
|
|
||||||
"enableFailed": "Error al activar el inicio al iniciar sesión",
|
|
||||||
"declineFailed": "Error al guardar la preferencia",
|
|
||||||
"tryAgain": "Por favor, inténtalo de nuevo"
|
|
||||||
},
|
|
||||||
"wayfernTerms": {
|
"wayfernTerms": {
|
||||||
"title": "Términos y condiciones de Wayfern",
|
"title": "Términos y condiciones de Wayfern",
|
||||||
"description": "Antes de usar Donut Browser, debes leer y aceptar los Términos y Condiciones de Wayfern.",
|
"description": "Antes de usar Donut Browser, debes leer y aceptar los Términos y Condiciones de Wayfern.",
|
||||||
@@ -1680,7 +1678,8 @@
|
|||||||
"viewRelease": "Ver lanzamiento",
|
"viewRelease": "Ver lanzamiento",
|
||||||
"later": "Más tarde",
|
"later": "Más tarde",
|
||||||
"uploading": "Subiendo",
|
"uploading": "Subiendo",
|
||||||
"downloading": "Descargando"
|
"downloading": "Descargando",
|
||||||
|
"startingUpdate": "Iniciando actualización..."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"browserDownload": {
|
"browserDownload": {
|
||||||
@@ -1694,7 +1693,9 @@
|
|||||||
"extractionFailedDescription": "El archivo dañado fue eliminado. Se volverá a descargar en el próximo intento.",
|
"extractionFailedDescription": "El archivo dañado fue eliminado. Se volverá a descargar en el próximo intento.",
|
||||||
"extracting": "Extrayendo archivos del navegador... No cierre la aplicación.",
|
"extracting": "Extrayendo archivos del navegador... No cierre la aplicación.",
|
||||||
"verifying": "Verificando archivos del navegador...",
|
"verifying": "Verificando archivos del navegador...",
|
||||||
"downloadingRolling": "Descargando compilación rolling release..."
|
"downloadingRolling": "Descargando compilación rolling release...",
|
||||||
|
"geoipDownloading": "Descargando base de datos GeoIP",
|
||||||
|
"geoipDownloaded": "¡Base de datos GeoIP descargada correctamente!"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"versionUpdater": {
|
"versionUpdater": {
|
||||||
@@ -1712,7 +1713,12 @@
|
|||||||
"updateSuccessDescription": "Se encontraron {{newVersions}} nuevas versiones en {{successfulUpdates}} navegadores. Las descargas automáticas comenzarán pronto.",
|
"updateSuccessDescription": "Se encontraron {{newVersions}} nuevas versiones en {{successfulUpdates}} navegadores. Las descargas automáticas comenzarán pronto.",
|
||||||
"upToDate": "No se encontraron nuevas versiones del navegador",
|
"upToDate": "No se encontraron nuevas versiones del navegador",
|
||||||
"upToDateDescription": "Todas las versiones del navegador están actualizadas",
|
"upToDateDescription": "Todas las versiones del navegador están actualizadas",
|
||||||
"updateAllFailed": "Error al actualizar las versiones del navegador"
|
"updateAllFailed": "Error al actualizar las versiones del navegador",
|
||||||
|
"updateStarted": "Actualización de {{browser}} iniciada",
|
||||||
|
"updateStartedDescription": "La descarga de la versión {{version}} comenzará en breve. El inicio del navegador está deshabilitado hasta que finalice la actualización.",
|
||||||
|
"downloadStarting": "Iniciando la descarga de {{browser}} {{version}}",
|
||||||
|
"downloadProgressBelow": "El progreso de la descarga se mostrará a continuación...",
|
||||||
|
"autoDownloadStarted": "Descargando {{browser}} {{version}} automáticamente. El progreso se mostrará a continuación."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profilePassword": {
|
"profilePassword": {
|
||||||
@@ -1800,7 +1806,11 @@
|
|||||||
"invalidLaunchHookUrl": "URL del hook de inicio no válida. Usa una URL completa http:// o https://.",
|
"invalidLaunchHookUrl": "URL del hook de inicio no válida. Usa una URL completa http:// o https://.",
|
||||||
"cookieDbLocked": "No se pudieron leer las cookies — la base de datos está bloqueada. Cierra el navegador e inténtalo de nuevo.",
|
"cookieDbLocked": "No se pudieron leer las cookies — la base de datos está bloqueada. Cierra el navegador e inténtalo de nuevo.",
|
||||||
"cookieDbUnavailable": "No se pudieron leer las cookies — el almacén de cookies no está disponible.",
|
"cookieDbUnavailable": "No se pudieron leer las cookies — el almacén de cookies no está disponible.",
|
||||||
"selfHostedRequiresLogout": "Cierra sesión en tu cuenta de Donut antes de configurar un servidor autoalojado."
|
"selfHostedRequiresLogout": "Cierra sesión en tu cuenta de Donut antes de configurar un servidor autoalojado.",
|
||||||
|
"fingerprintRequiresPro": "Ver o editar la huella digital requiere un plan de pago activo. La protección está incluida en todos los planes.",
|
||||||
|
"proxyNotWorking": "El proxy seleccionado no funciona, por lo que no se creó el perfil.",
|
||||||
|
"proxyPaymentRequired": "El proxy seleccionado requiere pago (402) —su suscripción puede haber vencido— por lo que no se creó el perfil.",
|
||||||
|
"vpnNotWorking": "La VPN seleccionada no funciona, por lo que no se creó el perfil."
|
||||||
},
|
},
|
||||||
"rail": {
|
"rail": {
|
||||||
"profiles": "Perfiles",
|
"profiles": "Perfiles",
|
||||||
@@ -1866,7 +1876,8 @@
|
|||||||
"plan": "Plan",
|
"plan": "Plan",
|
||||||
"status": "Estado",
|
"status": "Estado",
|
||||||
"teamRole": "Rol en el equipo",
|
"teamRole": "Rol en el equipo",
|
||||||
"period": "Período"
|
"period": "Período",
|
||||||
|
"device": "Dispositivo"
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"account": "Cuenta",
|
"account": "Cuenta",
|
||||||
@@ -1880,7 +1891,10 @@
|
|||||||
"statusUnknown": "Sin probar",
|
"statusUnknown": "Sin probar",
|
||||||
"testConnection": "Probar conexión",
|
"testConnection": "Probar conexión",
|
||||||
"disconnect": "Desconectar"
|
"disconnect": "Desconectar"
|
||||||
}
|
},
|
||||||
|
"deviceOrdinal": "{{ordinal}} de {{count}}",
|
||||||
|
"automationPrimaryOnly": "La automatización del navegador solo funciona en tu dispositivo principal (Dispositivo 1). Cierra sesión allí para usarla aquí.",
|
||||||
|
"automationActiveHere": "La automatización del navegador está activa en este dispositivo."
|
||||||
},
|
},
|
||||||
"shortcutsPage": {
|
"shortcutsPage": {
|
||||||
"title": "Atajos de teclado",
|
"title": "Atajos de teclado",
|
||||||
@@ -1912,5 +1926,96 @@
|
|||||||
"goIntegrations": "Ir a Integraciones",
|
"goIntegrations": "Ir a Integraciones",
|
||||||
"goAccount": "Ir a Cuenta",
|
"goAccount": "Ir a Cuenta",
|
||||||
"goSettings": "Ir a Configuración"
|
"goSettings": "Ir a Configuración"
|
||||||
|
},
|
||||||
|
"closeConfirm": {
|
||||||
|
"title": "¿Cerrar Donut Browser?",
|
||||||
|
"description": "¿Quieres enviar la aplicación a la bandeja del sistema o salir?",
|
||||||
|
"minimize": "Minimizar a la bandeja",
|
||||||
|
"quit": "Salir"
|
||||||
|
},
|
||||||
|
"tray": {
|
||||||
|
"show": "Mostrar Donut Browser",
|
||||||
|
"quit": "Salir"
|
||||||
|
},
|
||||||
|
"browserSupport": {
|
||||||
|
"endingSoonTitle": "El soporte del navegador finalizará pronto",
|
||||||
|
"endingSoonDescription": "El soporte para los siguientes perfiles se eliminará el 15 de marzo de 2026: {{profiles}}. Migra a perfiles de Wayfern o Camoufox."
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"steps": {
|
||||||
|
"createProfile": {
|
||||||
|
"title": "Crea tu primer perfil",
|
||||||
|
"content": "Haz clic aquí para crear tu primer perfil. Elige Wayfern como navegador: el Chromium recomendado y protegido contra huellas digitales."
|
||||||
|
},
|
||||||
|
"dnsBlocking": {
|
||||||
|
"title": "Bloqueo DNS",
|
||||||
|
"content": "Usa este menú para definir el nivel de la lista de bloqueo DNS del perfil: bloquea anuncios, rastreadores y malware a nivel de red. Los niveles más altos bloquean más."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"skip": "Omitir",
|
||||||
|
"back": "Atrás",
|
||||||
|
"next": "Siguiente",
|
||||||
|
"finish": "Finalizar"
|
||||||
|
},
|
||||||
|
"thankYou": {
|
||||||
|
"title": "Gracias por elegir Donut Browser",
|
||||||
|
"body": "Ojalá ayude a hacer tu navegación más privada: cada identidad por separado y sin que nada salga de tu equipo. ¡Que lo disfrutes!",
|
||||||
|
"cta": "Empezar a navegar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"welcome": {
|
||||||
|
"title": "Te damos la bienvenida a Donut Browser",
|
||||||
|
"tagline": "Un navegador antidetección de código abierto para gestionar muchas identidades a la vez.",
|
||||||
|
"skip": "Omitir",
|
||||||
|
"next": "Siguiente",
|
||||||
|
"permissions": {
|
||||||
|
"title": "Permitir micrófono y cámara",
|
||||||
|
"desc": "Concede acceso para que los sitios que necesitan micrófono o cámara funcionen en tus perfiles de navegador. macOS lo pregunta una vez; cada sitio te lo seguirá pidiendo por separado.",
|
||||||
|
"skip": "Ahora no",
|
||||||
|
"grant": "Permitir acceso",
|
||||||
|
"requesting": "Solicitando…"
|
||||||
|
},
|
||||||
|
"ready": {
|
||||||
|
"title": "Preparando todo",
|
||||||
|
"descDownloading": "Descargando tu primer navegador (Wayfern). Esta configuración única se ejecuta en segundo plano; espera un momento.",
|
||||||
|
"descReady": "Tu navegador está listo. Vamos a crear tu primer perfil.",
|
||||||
|
"cta": "Crear mi primer perfil",
|
||||||
|
"downloading": "Descargando…",
|
||||||
|
"extracting": "Extrayendo…",
|
||||||
|
"stats": "{{downloaded}} de {{total}}",
|
||||||
|
"speed": "{{speed}}/s",
|
||||||
|
"timeLeft": "{{time}} restante",
|
||||||
|
"descExtracting": "Extrayendo tu navegador. Esta configuración única se ejecuta en segundo plano: espera un momento.",
|
||||||
|
"almostFinished": "Casi terminado…",
|
||||||
|
"errorTitle": "Error en la configuración",
|
||||||
|
"errorDownload": "No se pudo descargar {{browser}}. Comprueba tu conexión e inténtalo de nuevo.",
|
||||||
|
"errorExtraction": "No se pudo extraer {{browser}}. Inténtalo de nuevo.",
|
||||||
|
"errorGeneric": "Algo salió mal al configurar {{browser}}. Inténtalo de nuevo.",
|
||||||
|
"retry": "Reintentar"
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"title": "Funciones",
|
||||||
|
"items": {
|
||||||
|
"setDefault": "Establecer como navegador predeterminado",
|
||||||
|
"proxy": "Compatibilidad con proxy (HTTP/SOCKS5)",
|
||||||
|
"vpn": "Compatibilidad con VPN (WireGuard)",
|
||||||
|
"profiles": "Perfiles locales ilimitados",
|
||||||
|
"api": "API de gestión de perfiles y MCP",
|
||||||
|
"openSource": "Código abierto",
|
||||||
|
"groups": "Grupos de perfiles",
|
||||||
|
"cookies": "Importar y exportar cookies"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"title": "Licencias",
|
||||||
|
"body": "Donut Browser es de código abierto y de uso gratuito.",
|
||||||
|
"agree": "Entendido",
|
||||||
|
"personalTitle": "Uso personal",
|
||||||
|
"personalDesc": "Gratis para siempre.",
|
||||||
|
"commercialTitle": "Uso comercial",
|
||||||
|
"trialBadge": "2 semanas gratis",
|
||||||
|
"commercialDesc": "Gratis durante una evaluación de 2 semanas. Después, un plan de pago mantiene el proyecto en buen estado y próspero."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user