mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-12 09:47:51 +02:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e98d02a585 | |||
| afa2326584 | |||
| d25d8549e4 | |||
| 662b370ed0 | |||
| b2d16c7be1 | |||
| a0244356bf | |||
| 14522c75f6 | |||
| b4624f8e8f | |||
| e5f12884de | |||
| c95b097c93 | |||
| 742b883090 | |||
| 57e068084e | |||
| e006d56387 | |||
| 43f9f02029 | |||
| 839265de35 | |||
| 0d85b61c96 | |||
| f581b6ec59 | |||
| 43c86c2dfb | |||
| 42067367fd | |||
| ce7213dccd | |||
| 799df28f61 | |||
| e501e7a260 | |||
| 801bd3fe90 | |||
| b4074c1ee6 | |||
| 08cde9c0dc | |||
| 98f1c7452a | |||
| ddfdf68dd1 | |||
| 2131ca3e3f | |||
| 3a3f201065 | |||
| ecafb5e1c0 | |||
| 17e33aa53f | |||
| 4436b69bf9 | |||
| 3bc9127c06 | |||
| 072cb24e5b | |||
| 3224faa2da | |||
| d067920392 | |||
| 9656f3f426 | |||
| f730fd958d | |||
| 2310292b35 | |||
| 0b6af0cb10 | |||
| b78ee14cbe | |||
| fdecf445ec | |||
| d5f260bd7e | |||
| 56c547d7e0 | |||
| 4396754cbd | |||
| 60c7c72036 | |||
| f81e8b6162 | |||
| e4ecd0d18a | |||
| 8bc2dc3102 | |||
| 55de231a37 | |||
| aab403fd9b | |||
| 667a4c99f0 | |||
| 9236ad38c8 | |||
| 6850f2c573 | |||
| 0add6c2aae | |||
| f54c359d15 | |||
| 69da467ce0 | |||
| 375530e358 | |||
| d664e5cde6 | |||
| 096e4aaf4a | |||
| 8305c45cb5 | |||
| ff3634e6cc | |||
| 36263eac04 | |||
| 9e777ed37b | |||
| 4d59805989 | |||
| 28d135de06 | |||
| d234172d0a | |||
| 6cd257c40b | |||
| 7446f678d4 | |||
| 72e2b99b9e | |||
| 98b83aaf5a | |||
| 99074280ea | |||
| 85586ed8fa |
@@ -0,0 +1,23 @@
|
|||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: |-
|
||||||
|
You write short, friendly release summaries for Donut Browser, an anti-detect browser desktop app built with Tauri and Next.js.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Keep it minimal and friendly. No marketing voice, no filler, no superlatives.
|
||||||
|
- No emojis or pictographic symbols.
|
||||||
|
- Plain ASCII punctuation only. No em-dashes, en-dashes, ellipses, smart quotes, or any non-ASCII characters. Use a regular hyphen, three dots, or straight quotes instead.
|
||||||
|
- Plain text only. No markdown (no asterisks for bold, no backticks for code, no headings), no HTML tags.
|
||||||
|
- Focus on user-visible changes. Skip chore, docs-only, CI, test, dependency, formatting, and purely internal refactor commits unless they have user-visible impact.
|
||||||
|
- Group related commits into a single bullet when it reads better.
|
||||||
|
- Use simple, direct language.
|
||||||
|
- Do not include the version number, download links, or a heading. The surrounding message already has those.
|
||||||
|
- If nothing in the commits is user-visible, output exactly one bullet: "- Small fixes and internal improvements."
|
||||||
|
- role: user
|
||||||
|
content: |-
|
||||||
|
Write the summary for Donut Browser {{version}} from these commits:
|
||||||
|
|
||||||
|
{{commits}}
|
||||||
|
|
||||||
|
Format: one short opening sentence, a blank line, then bullets starting with "- " (one per line). Nothing else.
|
||||||
|
model: openai/gpt-4.1
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
|
||||||
- name: Set up pnpm package manager
|
- name: Set up pnpm package manager
|
||||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
security-scan:
|
security-scan:
|
||||||
name: Security Vulnerability Scan
|
name: Security Vulnerability Scan
|
||||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||||
with:
|
with:
|
||||||
scan-args: |-
|
scan-args: |-
|
||||||
-r
|
-r
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 #v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee #v4.2.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
echo "Tags: ${TAGS}"
|
echo "Tags: ${TAGS}"
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f #v7.1.0
|
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf #v7.2.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./donut-sync/Dockerfile
|
file: ./donut-sync/Dockerfile
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
name: Issue Compliance Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
MODEL: z-ai/glm-5.1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-compliance:
|
||||||
|
# Maintainers' own issues are exempt — they open quick tracking issues
|
||||||
|
# without the template on purpose. Everyone else is checked.
|
||||||
|
if: >-
|
||||||
|
github.repository == 'zhom/donutbrowser' &&
|
||||||
|
github.event.issue.author_association != 'OWNER' &&
|
||||||
|
github.event.issue.author_association != 'MEMBER'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- 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 reviewing a new GitHub issue for template compliance. Return ONLY a single JSON object, no prose, no markdown fences.
|
||||||
|
|
||||||
|
Project: Donut Browser. There are three valid templates:
|
||||||
|
- Bug Report (Description + Operating System + Donut Browser version + Which browser is affected + Steps to reproduce + Error logs/screenshots fields)
|
||||||
|
- Feature Request (description + verification checkbox)
|
||||||
|
- Question (free form)
|
||||||
|
|
||||||
|
## Compliance — flag NON-compliant ONLY when at least one of these is true
|
||||||
|
- The issue body is empty or contains only placeholder text from the template
|
||||||
|
- The issue is an obvious AI-generated wall of text with no real specifics
|
||||||
|
- A bug report has no reproduction information or no error description
|
||||||
|
- A feature request gives no use case at all
|
||||||
|
- The author left required fields empty (Operating System, Donut Browser version, Which browser is affected, Steps to reproduce on bug reports)
|
||||||
|
|
||||||
|
Do NOT flag for missing optional fields, missing screenshots, short titles, or stylistic issues. Be conservative — a non-compliant verdict closes the issue, so only flag a genuine template violation.
|
||||||
|
|
||||||
|
## Output schema
|
||||||
|
{
|
||||||
|
"is_compliant": true | false,
|
||||||
|
"non_compliance_reasons": ["short bullet", ...]
|
||||||
|
}
|
||||||
|
|
||||||
|
If there is nothing to flag, return:
|
||||||
|
{"is_compliant": true, "non_compliance_reasons": []}
|
||||||
|
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: ("New issue title: " + $title + "\n\nNew issue body:\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
|
||||||
|
|
||||||
|
# Strip accidental markdown fences and parse. On parse failure, fall back
|
||||||
|
# to a compliant result so a flaky model never closes a legitimate issue.
|
||||||
|
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
|
||||||
|
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
|
||||||
|
echo "::warning::Model returned non-JSON; treating as compliant"
|
||||||
|
cat /tmp/raw.txt
|
||||||
|
echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json
|
||||||
|
fi
|
||||||
|
echo "Result:"
|
||||||
|
cat /tmp/result.json
|
||||||
|
|
||||||
|
- name: Build comment
|
||||||
|
id: build
|
||||||
|
run: |
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import json, os
|
||||||
|
r = json.load(open('/tmp/result.json'))
|
||||||
|
compliant = bool(r.get('is_compliant', True))
|
||||||
|
reasons = r.get('non_compliance_reasons') or []
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if not compliant:
|
||||||
|
parts.append("This issue was automatically closed because it doesn't follow our [issue templates](../issues/new/choose).")
|
||||||
|
parts.append('')
|
||||||
|
parts.append('**What was missing:**')
|
||||||
|
for reason in reasons:
|
||||||
|
parts.append(f'- {reason}')
|
||||||
|
parts.append('')
|
||||||
|
parts.append('If this is a real bug or feature request, please open a new issue using the **Bug Report** or **Feature Request** template and fill in the required fields. Issues that ignore the template are not triaged.')
|
||||||
|
|
||||||
|
comment = '\n'.join(parts).strip()
|
||||||
|
open('/tmp/comment.md', 'w').write(comment)
|
||||||
|
with open(os.environ['GITHUB_OUTPUT'], 'a') as fh:
|
||||||
|
fh.write(f'non_compliant={"true" if not compliant else "false"}\n')
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Comment and close non-compliant issue
|
||||||
|
if: steps.build.outputs.non_compliant == 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||||
|
run: |
|
||||||
|
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
|
||||||
|
gh issue close "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --reason "not planned"
|
||||||
@@ -18,8 +18,8 @@ permissions:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
# Single source of truth for the model used by both triage and composer.
|
# Single source of truth for the model used by both triage and composer.
|
||||||
TRIAGE_MODEL: anthropic/claude-opus-4.7
|
TRIAGE_MODEL: z-ai/glm-5.1
|
||||||
COMPOSER_MODEL: anthropic/claude-opus-4.7
|
COMPOSER_MODEL: z-ai/glm-5.1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze-issue:
|
analyze-issue:
|
||||||
@@ -102,12 +102,14 @@ jobs:
|
|||||||
its API, MCP server, and the bundled `donut-sync` self-hosted server.
|
its API, MCP server, and the bundled `donut-sync` self-hosted server.
|
||||||
- **Wayfern** — a Chromium fork maintained by zhom (the same maintainer). Wayfern
|
- **Wayfern** — a Chromium fork maintained by zhom (the same maintainer). Wayfern
|
||||||
bugs are in-scope here unless they are obviously upstream Chromium issues.
|
bugs are in-scope here unless they are obviously upstream Chromium issues.
|
||||||
- **Camoufox** — a Firefox fork by daijro. The maintainer of THIS repo does NOT
|
- **Camoufox** — a Firefox fork by daijro, used by Donut but maintained in a
|
||||||
contribute to Camoufox and CANNOT fix bugs in it.
|
separate repository. Bugs about Camoufox's *internal* behavior are outside
|
||||||
|
the scope of this project.
|
||||||
- Bugs about Camoufox's *internal* behavior (page rendering, JS engine,
|
- Bugs about Camoufox's *internal* behavior (page rendering, JS engine,
|
||||||
dropdowns, form widgets, fingerprinting *as Camoufox implements it*,
|
dropdowns, form widgets, fingerprinting *as Camoufox implements it*,
|
||||||
checkbox/radio quirks) are UPSTREAM ONLY. Redirect to
|
checkbox/radio quirks) are out of scope here. Ask the user to first
|
||||||
https://github.com/daijro/camoufox/issues.
|
search https://github.com/daijro/camoufox/issues for a matching report,
|
||||||
|
and if they don't find one, to open it there themselves.
|
||||||
- Bugs about how Donut *launches, configures, or downloads* Camoufox are
|
- Bugs about how Donut *launches, configures, or downloads* Camoufox are
|
||||||
in-scope here.
|
in-scope here.
|
||||||
- **Forks of Wayfern or Camoufox** (e.g. CloverLabsAI, VulpineOS) are NOT
|
- **Forks of Wayfern or Camoufox** (e.g. CloverLabsAI, VulpineOS) are NOT
|
||||||
@@ -146,7 +148,10 @@ jobs:
|
|||||||
dismiss as "known issue" / "expected" / "false positive in Tauri apps". Ask
|
dismiss as "known issue" / "expected" / "false positive in Tauri apps". Ask
|
||||||
which exact version was the last working one and what changed.
|
which exact version was the last working one and what changed.
|
||||||
- **Out-of-scope (upstream Camoufox)**: report is about Camoufox's own
|
- **Out-of-scope (upstream Camoufox)**: report is about Camoufox's own
|
||||||
behavior. Redirect, do not collect logs.
|
behavior. Tell the user it's outside the scope of this project and ask
|
||||||
|
them to search the Camoufox repo and, if no matching issue exists, file
|
||||||
|
one there. Do NOT say the maintainer doesn't contribute / can't fix it
|
||||||
|
— keep it strictly about project scope. Do not collect logs.
|
||||||
- **Fork-support request**: asks the maintainer to support an alternative
|
- **Fork-support request**: asks the maintainer to support an alternative
|
||||||
Wayfern/Camoufox fork. Acknowledge in one neutral sentence — do NOT call it
|
Wayfern/Camoufox fork. Acknowledge in one neutral sentence — do NOT call it
|
||||||
"clear", "reasonable", "well-thought-out", etc.
|
"clear", "reasonable", "well-thought-out", etc.
|
||||||
@@ -342,7 +347,7 @@ jobs:
|
|||||||
The triage classification (`triage.classification`) determines the response shape:
|
The triage classification (`triage.classification`) determines the response shape:
|
||||||
|
|
||||||
- `bug-in-scope`: ask for what is missing using the user's reported OS log path. Be concrete about how to obtain logs.
|
- `bug-in-scope`: ask for what is missing using the user's reported OS log path. Be concrete about how to obtain logs.
|
||||||
- `bug-upstream-camoufox`: redirect ONLY. One sentence acknowledging, then a sentence saying this is a Camoufox-internal issue and the maintainer of this repo does not contribute to Camoufox; ask the user to file at https://github.com/daijro/camoufox/issues. Do NOT ask for Donut logs. Stop after that.
|
- `bug-upstream-camoufox`: redirect ONLY. One sentence acknowledging, then say this is outside the scope of this project — ask the user to first search https://github.com/daijro/camoufox/issues for a matching report and, if none exists, to open one there themselves. Do NOT phrase it as "the maintainer does not contribute" or anything personal — keep it strictly about scope. Do NOT ask for Donut logs. Stop after that.
|
||||||
- `bug-template-violation` or `ai-generated-junk`: politely ask the user to refile using the bug-report template (the Operating System, Donut Browser version, Which browser, Steps to reproduce, Error logs sections). If they cited "documentation" from any non-`donutbrowser.com`/non-`github.com/zhom` URL (e.g. context7, deepwiki), gently note that those are AI-generated third-party summaries and the only authoritative sources are this repo and donutbrowser.com.
|
- `bug-template-violation` or `ai-generated-junk`: politely ask the user to refile using the bug-report template (the Operating System, Donut Browser version, Which browser, Steps to reproduce, Error logs sections). If they cited "documentation" from any non-`donutbrowser.com`/non-`github.com/zhom` URL (e.g. context7, deepwiki), gently note that those are AI-generated third-party summaries and the only authoritative sources are this repo and donutbrowser.com.
|
||||||
- `feature-request`: one neutral sentence acknowledging, then ask only what is genuinely needed (concrete use case, whether a workaround would suffice). Do NOT validate.
|
- `feature-request`: one neutral sentence acknowledging, then ask only what is genuinely needed (concrete use case, whether a workaround would suffice). Do NOT validate.
|
||||||
- `fork-request`: one neutral sentence acknowledging the request. Note that this would substantially increase support burden and the maintainer evaluates such requests on a case-by-case basis. Ask whether the alternative fork supports all platforms the user uses (macOS / Windows / Linux). No "clear enhancement" language.
|
- `fork-request`: one neutral sentence acknowledging the request. Note that this would substantially increase support burden and the maintainer evaluates such requests on a case-by-case basis. Ask whether the alternative fork supports all platforms the user uses (macOS / Windows / Linux). No "clear enhancement" language.
|
||||||
@@ -615,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@8ba2a9171597262df9d19516c82a5e14f18f5c63 #v1.14.41
|
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 }}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
|
||||||
- name: Set up pnpm package manager
|
- name: Set up pnpm package manager
|
||||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
|
||||||
- name: Set up pnpm package manager
|
- name: Set up pnpm package manager
|
||||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
models: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
notify:
|
notify:
|
||||||
@@ -105,21 +106,12 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Post release announcement to Telegram
|
- name: Collect commits between previous tag and current tag
|
||||||
|
id: commits
|
||||||
if: steps.gate.outputs.skip != 'true'
|
if: steps.gate.outputs.skip != 'true'
|
||||||
env:
|
env:
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
|
||||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
|
||||||
TAG: ${{ steps.tag.outputs.tag }}
|
TAG: ${{ steps.tag.outputs.tag }}
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
|
||||||
echo "::warning::TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set — skipping Telegram notification."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Find the previous stable tag (skip the current one) so the
|
|
||||||
# changelog range is well-defined.
|
|
||||||
PREV_TAG=$(git tag --sort=-version:refname \
|
PREV_TAG=$(git tag --sort=-version:refname \
|
||||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||||
| grep -v "^${TAG}$" \
|
| grep -v "^${TAG}$" \
|
||||||
@@ -127,29 +119,52 @@ jobs:
|
|||||||
if [ -z "$PREV_TAG" ]; then
|
if [ -z "$PREV_TAG" ]; then
|
||||||
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
|
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
|
||||||
fi
|
fi
|
||||||
|
git log --pretty=format:"- %s (%h)" "${PREV_TAG}..${TAG}" --no-merges > commits.txt
|
||||||
|
echo "previous-tag=${PREV_TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Collected $(wc -l < commits.txt) commits between ${PREV_TAG} and ${TAG}."
|
||||||
|
|
||||||
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
|
- name: Generate summary with AI
|
||||||
|
id: ai
|
||||||
|
if: steps.gate.outputs.skip != 'true'
|
||||||
|
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||||
|
with:
|
||||||
|
prompt-file: .github/prompts/telegram-release-summary.prompt.yml
|
||||||
|
input: |
|
||||||
|
version: ${{ steps.tag.outputs.tag }}
|
||||||
|
file_input: |
|
||||||
|
commits: ./commits.txt
|
||||||
|
max-tokens: 1024
|
||||||
|
|
||||||
# Build a plain bullet list from feat / fix / refactor commits.
|
- name: Post release announcement to Telegram
|
||||||
# Other commit types (chore, docs, ci, test, deps) are intentionally
|
if: steps.gate.outputs.skip != 'true'
|
||||||
# filtered out to keep the channel focused on user-visible changes.
|
env:
|
||||||
CHANGES=""
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
while IFS= read -r msg; do
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
[ -z "$msg" ] && continue
|
TAG: ${{ steps.tag.outputs.tag }}
|
||||||
case "$msg" in
|
REPO: ${{ github.repository }}
|
||||||
feat\(*\):*|feat:*|fix\(*\):*|fix:*|refactor\(*\):*|refactor:*)
|
AI_RESPONSE_FILE: ${{ steps.ai.outputs.response-file }}
|
||||||
CHANGES="${CHANGES}• $(strip_prefix "$msg")"$'\n'
|
AI_RESPONSE: ${{ steps.ai.outputs.response }}
|
||||||
;;
|
run: |
|
||||||
esac
|
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||||
done < <(git log --pretty=format:%s "${PREV_TAG}..${TAG}")
|
echo "::warning::TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set — skipping Telegram notification."
|
||||||
|
exit 0
|
||||||
if [ -z "$CHANGES" ]; then
|
|
||||||
CHANGES="• See release notes."$'\n'
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# HTML-escape the changelog before injecting into Telegram HTML
|
# Prefer the file output — `response` can be truncated for longer summaries.
|
||||||
# mode — commit messages can legitimately contain `<`, `>`, `&`.
|
if [ -n "$AI_RESPONSE_FILE" ] && [ -f "$AI_RESPONSE_FILE" ]; then
|
||||||
ESCAPED_CHANGES=$(printf '%s' "$CHANGES" \
|
SUMMARY=$(cat "$AI_RESPONSE_FILE")
|
||||||
|
else
|
||||||
|
SUMMARY="$AI_RESPONSE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${SUMMARY//[[:space:]]/}" ]; then
|
||||||
|
echo "::error::AI summary is empty"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# HTML-escape the AI summary before injecting into Telegram HTML mode —
|
||||||
|
# commit messages can legitimately contain `<`, `>`, `&` and the AI may echo them.
|
||||||
|
ESCAPED_CHANGES=$(printf '%s' "$SUMMARY" \
|
||||||
| python3 -c "import html, sys; sys.stdout.write(html.escape(sys.stdin.read()))")
|
| python3 -c "import html, sys; sys.stdout.write(html.escape(sys.stdin.read()))")
|
||||||
|
|
||||||
VERSION="${TAG}"
|
VERSION="${TAG}"
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ jobs:
|
|||||||
scan-scheduled:
|
scan-scheduled:
|
||||||
name: Scheduled Security Scan
|
name: Scheduled Security Scan
|
||||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||||
with:
|
with:
|
||||||
scan-args: |-
|
scan-args: |-
|
||||||
-r
|
-r
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
scan-pr:
|
scan-pr:
|
||||||
name: PR Security Scan
|
name: PR Security Scan
|
||||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||||
with:
|
with:
|
||||||
scan-args: |-
|
scan-args: |-
|
||||||
-r
|
-r
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
security-scan:
|
security-scan:
|
||||||
name: Security Vulnerability Scan
|
name: Security Vulnerability Scan
|
||||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||||
with:
|
with:
|
||||||
scan-args: |-
|
scan-args: |-
|
||||||
-r
|
-r
|
||||||
|
|||||||
@@ -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@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
|
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: |
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
security-scan:
|
security-scan:
|
||||||
if: github.repository == 'zhom/donutbrowser'
|
if: github.repository == 'zhom/donutbrowser'
|
||||||
name: Security Vulnerability Scan
|
name: Security Vulnerability Scan
|
||||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||||
with:
|
with:
|
||||||
scan-args: |-
|
scan-args: |-
|
||||||
-r
|
-r
|
||||||
@@ -108,7 +108,7 @@ jobs:
|
|||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
security-scan:
|
security-scan:
|
||||||
if: github.repository == 'zhom/donutbrowser'
|
if: github.repository == 'zhom/donutbrowser'
|
||||||
name: Security Vulnerability Scan
|
name: Security Vulnerability Scan
|
||||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||||
with:
|
with:
|
||||||
scan-args: |-
|
scan-args: |-
|
||||||
-r
|
-r
|
||||||
@@ -107,7 +107,7 @@ jobs:
|
|||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
@@ -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@5374cbf686e897b15713110e233094e2874de7ef #v1.46.1
|
uses: crate-ci/typos@f8a58b6b53f2279f71eb605f03a4ae4d10608f45 #v1.47.0
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open."
|
stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open."
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
uses: actions/checkout@v6.0.2
|
uses: actions/checkout@v6.0.2
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 #v6.0.8
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -53,6 +53,18 @@ donutbrowser/
|
|||||||
- After making changes, run `pnpm format && pnpm lint && pnpm test` at the root of the project
|
- After making changes, run `pnpm format && pnpm lint && pnpm test` at the root of the project
|
||||||
- Always run this command before finishing a task to ensure the application isn't broken
|
- Always run this command before finishing a task to ensure the application isn't broken
|
||||||
- `pnpm lint` includes spellcheck via [typos](https://github.com/crate-ci/typos). False positives can be allowlisted in `_typos.toml`
|
- `pnpm lint` includes spellcheck via [typos](https://github.com/crate-ci/typos). False positives can be allowlisted in `_typos.toml`
|
||||||
|
- The full `pnpm test` output dumps every test name (≈400+ lines) which burns context for no signal. Filter:
|
||||||
|
`pnpm test 2>&1 | grep -E "test result|panicked|FAILED"` — four "test result: ok" lines means everything passed.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
@@ -64,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)
|
||||||
|
|
||||||
@@ -83,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.
|
||||||
|
|
||||||
@@ -122,6 +134,34 @@ A `<Dialog>` becomes a first-class app sub-page (no modal overlay, no center pos
|
|||||||
|
|
||||||
Reference implementations: `src/components/account-page.tsx`, `src/components/proxy-management-dialog.tsx`. Reuse the exact class strings — the overrides are tuned to match the rest of the sub-page chrome.
|
Reference implementations: `src/components/account-page.tsx`, `src/components/proxy-management-dialog.tsx`. Reuse the exact class strings — the overrides are tuned to match the rest of the sub-page chrome.
|
||||||
|
|
||||||
|
### Cross-component tab control
|
||||||
|
|
||||||
|
When a tabbed sub-page dialog needs to be opened to a specific tab by an external trigger (e.g. a keyboard shortcut that toggles `proxies` ↔ `vpns`), expose an `initialTab` prop and key the `Tabs` component off it. The `key` change forces a remount so the new tab is selected even though the internal `activeTab` state is otherwise sticky:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<AnimatedTabs key={initialTab} defaultValue={initialTab} ...>
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference implementations: `proxy-management-dialog.tsx`, `extension-management-dialog.tsx`, `integrations-dialog.tsx`. The owning page in `src/app/page.tsx` keeps one piece of `useState` per dialog (`proxyManagementInitialTab`, `extensionManagementInitialTab`, `integrationsInitialTab`) and flips it on repeated shortcut presses.
|
||||||
|
|
||||||
|
## Keyboard shortcuts
|
||||||
|
|
||||||
|
All app-wide shortcuts live in `src/lib/shortcuts.ts`:
|
||||||
|
|
||||||
|
- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in all nine locales.
|
||||||
|
- `formatShortcut(s)` returns platform-correct token strings (`["⌘", "K"]` on mac, `["Ctrl", "K"]` elsewhere) — used by both the shortcuts page and the command palette.
|
||||||
|
- `matchesShortcut(s, event)` matches a real `KeyboardEvent` and rejects the wrong-platform modifier so Ctrl+K on macOS never fires a `mod: true` shortcut.
|
||||||
|
- `matchesGroupDigit(event)` returns 1–9 if Mod+digit was pressed — group switching is dynamic (driven by `orderedGroupTargets` in `page.tsx`) and isn't in the `SHORTCUTS` table.
|
||||||
|
|
||||||
|
Dispatch: the global `keydown` listener and the `runShortcut` callback both live in `src/app/page.tsx`. To add a new static shortcut:
|
||||||
|
|
||||||
|
1. Append to `SHORTCUTS` in `src/lib/shortcuts.ts`. Add the `ShortcutId` variant.
|
||||||
|
2. Add a `case "yourId":` in `runShortcut` in `page.tsx`.
|
||||||
|
3. Add the icon mapping in `src/components/command-palette.tsx::ICONS`.
|
||||||
|
4. Add `shortcuts.yourId` (label) to all 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.
|
||||||
|
|
||||||
## Singletons
|
## Singletons
|
||||||
|
|
||||||
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
|
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
|
||||||
@@ -176,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.
|
||||||
|
|||||||
+112
@@ -1,6 +1,118 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
|
||||||
|
## v0.25.2 (2026-06-02)
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- cleanup
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- update CHANGELOG.md and README.md for v0.25.1 [skip ci] (#412)
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- chore: simplify linux repo publish
|
||||||
|
- chore: version bump
|
||||||
|
- chore: copy
|
||||||
|
- chore: update flake.nix for v0.25.1 [skip ci] (#413)
|
||||||
|
|
||||||
|
|
||||||
|
## v0.25.1 (2026-06-01)
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- chore: version bump
|
||||||
|
- chore: update issue validation
|
||||||
|
- chore: cleanup windows ci
|
||||||
|
- chore: add missing keys
|
||||||
|
|
||||||
|
|
||||||
|
## v0.25.0 (2026-06-01)
|
||||||
|
|
||||||
|
Note: created manually due to CI issue
|
||||||
|
|
||||||
|
- Onboarding added for new users.
|
||||||
|
- When closing the window, you can choose to minimize to tray or quit.
|
||||||
|
- Improved feedback for macOS permission grants.
|
||||||
|
- Cloud login now opens in your external browser.
|
||||||
|
|
||||||
|
## v0.24.4 (2026-05-26)
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- more robust camoufox proxy handling
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- update CHANGELOG.md and README.md for v0.24.3 [skip ci] (#382)
|
||||||
|
- readme
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- chore: version bump
|
||||||
|
- chore: update flake.nix for v0.24.3 [skip ci] (#383)
|
||||||
|
|
||||||
|
|
||||||
|
## v0.24.3 (2026-05-25)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- add shortcuts
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- track gecko_id for extension groups
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- cleanup
|
||||||
|
- cleanup, korean translation
|
||||||
|
- reduce token usage
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- chore: version bump
|
||||||
|
- chore: linting
|
||||||
|
- chore: update pnpm
|
||||||
|
- chore: make telegram releases ai-generated
|
||||||
|
- chore: workflow cleanup
|
||||||
|
- ci(deps): bump the github-actions group with 6 updates
|
||||||
|
- chore: use less tokens
|
||||||
|
- chore: improve issue validation
|
||||||
|
- ci(deps): bump the github-actions group across 1 directory with 6 updates
|
||||||
|
- chore: update flake.nix for v0.24.2 [skip ci] (#370)
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- deps(rust)(deps): bump the rust-dependencies group
|
||||||
|
- deps(rust)(deps): bump the rust-dependencies group
|
||||||
|
|
||||||
|
|
||||||
|
## v0.24.2 (2026-05-16)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- more mcp integrations
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- camoufox proxy pid connection
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- browser update
|
||||||
|
- ui cleanup
|
||||||
|
- cleanup
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- chore: version bump
|
||||||
|
- chore: cleanup
|
||||||
|
- chore: update flake.nix for v0.24.1 [skip ci] (#364)
|
||||||
|
|
||||||
|
|
||||||
## v0.24.1 (2026-05-12)
|
## v0.24.1 (2026-05-12)
|
||||||
|
|
||||||
### Refactoring
|
### Refactoring
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@ codeql database analyze /tmp/codeql-rust --format=sarifv2.1.0 --output=/tmp/rust
|
|||||||
|
|
||||||
## Key Rules
|
## Key Rules
|
||||||
|
|
||||||
- **Translations**: Any UI text changes must be reflected in all 7 locale files (`src/i18n/locales/`)
|
- **Translations**: Any UI text changes must be reflected in all 9 locale files (`src/i18n/locales/`)
|
||||||
- **Tauri commands**: If you modify Tauri commands, the `test_no_unused_tauri_commands` test will catch unused ones
|
- **Tauri commands**: If you modify Tauri commands, the `test_no_unused_tauri_commands` test will catch unused ones
|
||||||
- **No hardcoded colors**: Use theme CSS variables (see `src/lib/themes.ts`), never Tailwind color classes like `text-red-500`
|
- **No hardcoded colors**: Use theme CSS variables (see `src/lib/themes.ts`), never Tailwind color classes like `text-red-500`
|
||||||
- **No lock file changes**: Don't update `pnpm-lock.yaml` or `Cargo.lock` unless updating dependencies is the purpose of the PR
|
- **No lock file changes**: Don't update `pnpm-lock.yaml` or `Cargo.lock` unless updating dependencies is the purpose of the PR
|
||||||
|
|||||||
@@ -19,9 +19,6 @@
|
|||||||
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/network/members" target="_blank">
|
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/network/members" target="_blank">
|
||||||
<img src="https://img.shields.io/github/forks/zhom/donutbrowser?style=social" alt="GitHub forks">
|
<img src="https://img.shields.io/github/forks/zhom/donutbrowser?style=social" alt="GitHub forks">
|
||||||
</a>
|
</a>
|
||||||
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/releases" target="_blank">
|
|
||||||
<img src="https://img.shields.io/github/downloads/zhom/donutbrowser/total" alt="Downloads">
|
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<img alt="Donut Browser Preview" src="assets/donut-preview.png" />
|
<img alt="Donut Browser Preview" src="assets/donut-preview.png" />
|
||||||
@@ -30,6 +27,7 @@
|
|||||||
|
|
||||||
- **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data
|
- **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data
|
||||||
- **Chromium & Firefox engines** — Chromium powered by [Wayfern](https://wayfern.com), Firefox powered by [Camoufox](https://camoufox.com), both with advanced fingerprint spoofing
|
- **Chromium & Firefox engines** — Chromium powered by [Wayfern](https://wayfern.com), Firefox powered by [Camoufox](https://camoufox.com), both with advanced fingerprint spoofing
|
||||||
|
- **DNS AdBlocker** - block ads, trackers, and other unwanted content with per-profile DNS blocking
|
||||||
- **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
|
- **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
|
||||||
- **VPN support** — WireGuard configs per profile
|
- **VPN support** — WireGuard configs per profile
|
||||||
- **Local API & MCP** — REST API and [Model Context Protocol](https://modelcontextprotocol.io) server for integration with Claude, automation tools, and custom workflows
|
- **Local API & MCP** — REST API and [Model Context Protocol](https://modelcontextprotocol.io) server for integration with Claude, automation tools, and custom workflows
|
||||||
@@ -48,7 +46,7 @@
|
|||||||
|
|
||||||
| | Apple Silicon | Intel |
|
| | Apple Silicon | Intel |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_x64.dmg) |
|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_x64.dmg) |
|
||||||
|
|
||||||
Or install via Homebrew:
|
Or install via Homebrew:
|
||||||
|
|
||||||
@@ -58,15 +56,15 @@ brew install --cask donut
|
|||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_x64-portable.zip)
|
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_x64-portable.zip)
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
| Format | x86_64 | ARM64 |
|
| Format | x86_64 | ARM64 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_arm64.deb) |
|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_arm64.deb) |
|
||||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut-0.24.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut-0.24.1-1.aarch64.rpm) |
|
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut-0.25.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut-0.25.2-1.aarch64.rpm) |
|
||||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.1/Donut_0.24.1_aarch64.AppImage) |
|
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_aarch64.AppImage) |
|
||||||
<!-- install-links-end -->
|
<!-- install-links-end -->
|
||||||
|
|
||||||
Or install via package manager:
|
Or install via package manager:
|
||||||
@@ -137,6 +135,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|||||||
<sub><b>Hassiy</b></sub>
|
<sub><b>Hassiy</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/webees">
|
||||||
|
<img src="https://avatars.githubusercontent.com/u/5155291?v=4" width="100;" alt="webees"/>
|
||||||
|
<br />
|
||||||
|
<sub><b>JockLee</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/yb403">
|
<a href="https://github.com/yb403">
|
||||||
<img src="https://avatars.githubusercontent.com/u/87396571?v=4" width="100;" alt="yb403"/>
|
<img src="https://avatars.githubusercontent.com/u/87396571?v=4" width="100;" alt="yb403"/>
|
||||||
@@ -144,6 +149,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|||||||
<sub><b>yb403</b></sub>
|
<sub><b>yb403</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/huy97">
|
||||||
|
<img src="https://avatars.githubusercontent.com/u/30153437?v=4" width="100;" alt="huy97"/>
|
||||||
|
<br />
|
||||||
|
<sub><b>Huy Le</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/drunkod">
|
<a href="https://github.com/drunkod">
|
||||||
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="drunkod"/>
|
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="drunkod"/>
|
||||||
@@ -151,6 +163,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|||||||
<sub><b>drunkod</b></sub>
|
<sub><b>drunkod</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/JorySeverijnse">
|
<a href="https://github.com/JorySeverijnse">
|
||||||
<img src="https://avatars.githubusercontent.com/u/117462355?v=4" width="100;" alt="JorySeverijnse"/>
|
<img src="https://avatars.githubusercontent.com/u/117462355?v=4" width="100;" alt="JorySeverijnse"/>
|
||||||
|
|||||||
+3
-1
@@ -3,7 +3,9 @@ extend-exclude = [
|
|||||||
"src-tauri/src/camoufox/data/*.json",
|
"src-tauri/src/camoufox/data/*.json",
|
||||||
"src-tauri/src/camoufox/data/*.xml",
|
"src-tauri/src/camoufox/data/*.xml",
|
||||||
"src/i18n/locales/*.json",
|
"src/i18n/locales/*.json",
|
||||||
"src-tauri/build.rs",
|
# Auto-generated from commit subjects by release.yml; typos here originate
|
||||||
|
# in commit messages, which are immutable, so don't spell-check it.
|
||||||
|
"CHANGELOG.md",
|
||||||
]
|
]
|
||||||
|
|
||||||
[default.extend-words]
|
[default.extend-words]
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 623 KiB After Width: | Height: | Size: 508 KiB |
@@ -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.1";
|
releaseVersion = "0.25.2";
|
||||||
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.1/Donut_0.24.1_amd64.AppImage";
|
url = "https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_amd64.AppImage";
|
||||||
hash = "sha256-nJ4WmbXQcnXWDaneucOlwzZmlOOBx+G/qDeCHH6/Vno=";
|
hash = "sha256-awESxsKfrSJFMAGbTasbXjL8UnF58ziLnS8Ee0phgb8=";
|
||||||
}
|
}
|
||||||
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.1/Donut_0.24.1_aarch64.AppImage";
|
url = "https://github.com/zhom/donutbrowser/releases/download/v0.25.2/Donut_0.25.2_aarch64.AppImage";
|
||||||
hash = "sha256-aLzHAdn+o9YsnKtK5BpjjrzAAbp/itsN1QdELTpHyTQ=";
|
hash = "sha256-zOUWnvf+5stknWomHwYRUw2TR0aS4/XeiVySBjHuJLA=";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
null;
|
null;
|
||||||
|
|||||||
+7
-12
@@ -2,7 +2,7 @@
|
|||||||
"name": "donutbrowser",
|
"name": "donutbrowser",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"version": "0.24.2",
|
"version": "0.25.3",
|
||||||
"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",
|
||||||
@@ -89,17 +94,7 @@
|
|||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"packageManager": "pnpm@11.2.2",
|
||||||
"overrides": {
|
|
||||||
"picomatch@>=4.0.0 <4.0.4": ">=4.0.4",
|
|
||||||
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0",
|
|
||||||
"postcss@<8.5.10": ">=8.5.12",
|
|
||||||
"fast-xml-parser@<5.7.0": ">=5.7.2",
|
|
||||||
"fast-uri@<3.1.2": ">=3.1.2",
|
|
||||||
"fast-xml-builder@<1.2.0": ">=1.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packageManager": "pnpm@10.33.2",
|
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"**/*.{js,jsx,ts,tsx,json,css}": [
|
"**/*.{js,jsx,ts,tsx,json,css}": [
|
||||||
"biome check --fix"
|
"biome check --fix"
|
||||||
|
|||||||
Generated
+92
-26
@@ -11,6 +11,8 @@ overrides:
|
|||||||
fast-xml-parser@<5.7.0: '>=5.7.2'
|
fast-xml-parser@<5.7.0: '>=5.7.2'
|
||||||
fast-uri@<3.1.2: '>=3.1.2'
|
fast-uri@<3.1.2: '>=3.1.2'
|
||||||
fast-xml-builder@<1.2.0: '>=1.2.0'
|
fast-xml-builder@<1.2.0: '>=1.2.0'
|
||||||
|
qs@>=6.11.1 <6.15.2: '>=6.15.2'
|
||||||
|
js-cookie@<3.0.7: '>=3.0.7'
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
@@ -31,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)
|
||||||
@@ -82,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
|
||||||
@@ -97,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)
|
||||||
@@ -112,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)
|
||||||
@@ -149,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
|
||||||
@@ -212,7 +229,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@nestjs/cli':
|
'@nestjs/cli':
|
||||||
specifier: ^11.0.21
|
specifier: ^11.0.21
|
||||||
version: 11.0.21(@types/node@25.7.0)(lightningcss@1.32.0)
|
version: 11.0.21(@types/node@25.7.0)
|
||||||
'@nestjs/schematics':
|
'@nestjs/schematics':
|
||||||
specifier: ^11.1.0
|
specifier: ^11.1.0
|
||||||
version: 11.1.0(chokidar@4.0.3)(typescript@6.0.3)
|
version: 11.1.0(chokidar@4.0.3)(typescript@6.0.3)
|
||||||
@@ -248,7 +265,7 @@ importers:
|
|||||||
version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.4.2(@types/node@25.7.0)(ts-node@10.9.2(@types/node@25.7.0)(typescript@6.0.3)))(typescript@6.0.3)
|
version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.4.2(@types/node@25.7.0)(ts-node@10.9.2(@types/node@25.7.0)(typescript@6.0.3)))(typescript@6.0.3)
|
||||||
ts-loader:
|
ts-loader:
|
||||||
specifier: ^9.5.7
|
specifier: ^9.5.7
|
||||||
version: 9.5.7(typescript@6.0.3)(webpack@5.106.0(lightningcss@1.32.0))
|
version: 9.5.7(typescript@6.0.3)(webpack@5.106.0)
|
||||||
ts-node:
|
ts-node:
|
||||||
specifier: ^10.9.2
|
specifier: ^10.9.2
|
||||||
version: 10.9.2(@types/node@25.7.0)(typescript@6.0.3)
|
version: 10.9.2(@types/node@25.7.0)(typescript@6.0.3)
|
||||||
@@ -1671,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:
|
||||||
@@ -2060,6 +2090,7 @@ packages:
|
|||||||
'@smithy/core@3.24.1':
|
'@smithy/core@3.24.1':
|
||||||
resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==}
|
resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
deprecated: Deprecated due to bug in browser bundling instructions https://github.com/smithy-lang/smithy-typescript/issues/2025
|
||||||
|
|
||||||
'@smithy/credential-provider-imds@4.3.1':
|
'@smithy/credential-provider-imds@4.3.1':
|
||||||
resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==}
|
resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==}
|
||||||
@@ -2480,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==}
|
||||||
|
|
||||||
@@ -3009,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'}
|
||||||
@@ -3872,9 +3909,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
js-cookie@3.0.5:
|
js-cookie@3.0.7:
|
||||||
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
|
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
@@ -4282,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==}
|
||||||
|
|
||||||
@@ -4401,8 +4447,8 @@ packages:
|
|||||||
pure-rand@7.0.1:
|
pure-rand@7.0.1:
|
||||||
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
|
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
|
||||||
|
|
||||||
qs@6.15.1:
|
qs@6.15.2:
|
||||||
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
|
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
|
|
||||||
radix-ui@1.4.3:
|
radix-ui@1.4.3:
|
||||||
@@ -6421,7 +6467,7 @@ snapshots:
|
|||||||
'@tybys/wasm-util': 0.10.2
|
'@tybys/wasm-util': 0.10.2
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@nestjs/cli@11.0.21(@types/node@25.7.0)(lightningcss@1.32.0)':
|
'@nestjs/cli@11.0.21(@types/node@25.7.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
|
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
|
||||||
'@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
|
'@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
|
||||||
@@ -6432,14 +6478,14 @@ snapshots:
|
|||||||
chokidar: 4.0.3
|
chokidar: 4.0.3
|
||||||
cli-table3: 0.6.5
|
cli-table3: 0.6.5
|
||||||
commander: 4.1.1
|
commander: 4.1.1
|
||||||
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(lightningcss@1.32.0))
|
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0)
|
||||||
glob: 13.0.6
|
glob: 13.0.6
|
||||||
node-emoji: 1.11.0
|
node-emoji: 1.11.0
|
||||||
ora: 5.4.1
|
ora: 5.4.1
|
||||||
tsconfig-paths: 4.2.0
|
tsconfig-paths: 4.2.0
|
||||||
tsconfig-paths-webpack-plugin: 4.2.0
|
tsconfig-paths-webpack-plugin: 4.2.0
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
webpack: 5.106.0(lightningcss@1.32.0)
|
webpack: 5.106.0
|
||||||
webpack-node-externals: 3.0.0
|
webpack-node-externals: 3.0.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@minify-html/node'
|
- '@minify-html/node'
|
||||||
@@ -6999,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)
|
||||||
@@ -7819,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
|
||||||
@@ -8125,7 +8183,7 @@ snapshots:
|
|||||||
'@types/js-cookie': 3.0.6
|
'@types/js-cookie': 3.0.6
|
||||||
dayjs: 1.11.20
|
dayjs: 1.11.20
|
||||||
intersection-observer: 0.12.2
|
intersection-observer: 0.12.2
|
||||||
js-cookie: 3.0.5
|
js-cookie: 3.0.7
|
||||||
lodash: 4.18.1
|
lodash: 4.18.1
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
@@ -8295,7 +8353,7 @@ snapshots:
|
|||||||
http-errors: 2.0.1
|
http-errors: 2.0.1
|
||||||
iconv-lite: 0.7.2
|
iconv-lite: 0.7.2
|
||||||
on-finished: 2.4.1
|
on-finished: 2.4.1
|
||||||
qs: 6.15.1
|
qs: 6.15.2
|
||||||
raw-body: 3.0.2
|
raw-body: 3.0.2
|
||||||
type-is: 2.0.1
|
type-is: 2.0.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -8369,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
|
||||||
@@ -8733,7 +8793,7 @@ snapshots:
|
|||||||
once: 1.4.0
|
once: 1.4.0
|
||||||
parseurl: 1.3.3
|
parseurl: 1.3.3
|
||||||
proxy-addr: 2.0.7
|
proxy-addr: 2.0.7
|
||||||
qs: 6.15.1
|
qs: 6.15.2
|
||||||
range-parser: 1.2.1
|
range-parser: 1.2.1
|
||||||
router: 2.2.0
|
router: 2.2.0
|
||||||
send: 1.2.1
|
send: 1.2.1
|
||||||
@@ -8804,7 +8864,7 @@ snapshots:
|
|||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
signal-exit: 4.1.0
|
signal-exit: 4.1.0
|
||||||
|
|
||||||
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(lightningcss@1.32.0)):
|
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.0
|
'@babel/code-frame': 7.29.0
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
@@ -8819,7 +8879,7 @@ snapshots:
|
|||||||
semver: 7.8.0
|
semver: 7.8.0
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
webpack: 5.106.0(lightningcss@1.32.0)
|
webpack: 5.106.0
|
||||||
|
|
||||||
form-data@4.0.5:
|
form-data@4.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9382,7 +9442,7 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.7.0: {}
|
jiti@2.7.0: {}
|
||||||
|
|
||||||
js-cookie@3.0.5: {}
|
js-cookie@3.0.7: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
@@ -9723,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
|
||||||
@@ -9834,7 +9902,7 @@ snapshots:
|
|||||||
|
|
||||||
pure-rand@7.0.1: {}
|
pure-rand@7.0.1: {}
|
||||||
|
|
||||||
qs@6.15.1:
|
qs@6.15.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel: 1.1.0
|
side-channel: 1.1.0
|
||||||
|
|
||||||
@@ -10294,7 +10362,7 @@ snapshots:
|
|||||||
formidable: 3.5.4
|
formidable: 3.5.4
|
||||||
methods: 1.1.2
|
methods: 1.1.2
|
||||||
mime: 2.6.0
|
mime: 2.6.0
|
||||||
qs: 6.15.1
|
qs: 6.15.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -10330,15 +10398,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.11.0
|
'@tauri-apps/api': 2.11.0
|
||||||
|
|
||||||
terser-webpack-plugin@5.6.0(lightningcss@1.32.0)(webpack@5.106.0(lightningcss@1.32.0)):
|
terser-webpack-plugin@5.6.0(webpack@5.106.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
jest-worker: 27.5.1
|
jest-worker: 27.5.1
|
||||||
schema-utils: 4.3.3
|
schema-utils: 4.3.3
|
||||||
terser: 5.47.1
|
terser: 5.47.1
|
||||||
webpack: 5.106.0(lightningcss@1.32.0)
|
webpack: 5.106.0
|
||||||
optionalDependencies:
|
|
||||||
lightningcss: 1.32.0
|
|
||||||
|
|
||||||
terser@5.47.1:
|
terser@5.47.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -10391,7 +10457,7 @@ snapshots:
|
|||||||
babel-jest: 30.4.1(@babel/core@7.29.0)
|
babel-jest: 30.4.1(@babel/core@7.29.0)
|
||||||
jest-util: 30.4.1
|
jest-util: 30.4.1
|
||||||
|
|
||||||
ts-loader@9.5.7(typescript@6.0.3)(webpack@5.106.0(lightningcss@1.32.0)):
|
ts-loader@9.5.7(typescript@6.0.3)(webpack@5.106.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
enhanced-resolve: 5.21.3
|
enhanced-resolve: 5.21.3
|
||||||
@@ -10399,7 +10465,7 @@ snapshots:
|
|||||||
semver: 7.8.0
|
semver: 7.8.0
|
||||||
source-map: 0.7.6
|
source-map: 0.7.6
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
webpack: 5.106.0(lightningcss@1.32.0)
|
webpack: 5.106.0
|
||||||
|
|
||||||
ts-node@10.9.2(@types/node@25.7.0)(typescript@6.0.3):
|
ts-node@10.9.2(@types/node@25.7.0)(typescript@6.0.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -10588,7 +10654,7 @@ snapshots:
|
|||||||
|
|
||||||
webpack-sources@3.4.1: {}
|
webpack-sources@3.4.1: {}
|
||||||
|
|
||||||
webpack@5.106.0(lightningcss@1.32.0):
|
webpack@5.106.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/eslint-scope': 3.7.7
|
'@types/eslint-scope': 3.7.7
|
||||||
'@types/estree': 1.0.9
|
'@types/estree': 1.0.9
|
||||||
@@ -10612,7 +10678,7 @@ snapshots:
|
|||||||
neo-async: 2.6.2
|
neo-async: 2.6.2
|
||||||
schema-utils: 4.3.3
|
schema-utils: 4.3.3
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
terser-webpack-plugin: 5.6.0(lightningcss@1.32.0)(webpack@5.106.0(lightningcss@1.32.0))
|
terser-webpack-plugin: 5.6.0(webpack@5.106.0)
|
||||||
watchpack: 2.5.1
|
watchpack: 2.5.1
|
||||||
webpack-sources: 3.4.1
|
webpack-sources: 3.4.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|||||||
@@ -11,3 +11,25 @@ onlyBuiltDependencies:
|
|||||||
- sharp
|
- sharp
|
||||||
- sqlite3
|
- sqlite3
|
||||||
- unrs-resolver
|
- unrs-resolver
|
||||||
|
|
||||||
|
# Husky and lint-staged shell out to pnpm without a TTY, so the interactive
|
||||||
|
# "purge modules dir?" prompt errors out (ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY)
|
||||||
|
# and aborts the commit. Skipping the prompt lets the hook proceed.
|
||||||
|
confirmModulesPurge: false
|
||||||
|
|
||||||
|
# Pinned for security. Moved from package.json#pnpm.overrides — pnpm 11
|
||||||
|
# no longer reads that field; settings live here now.
|
||||||
|
overrides:
|
||||||
|
picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
|
||||||
|
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
|
||||||
|
postcss@<8.5.10: '>=8.5.12'
|
||||||
|
fast-xml-parser@<5.7.0: '>=5.7.2'
|
||||||
|
fast-uri@<3.1.2: '>=3.1.2'
|
||||||
|
fast-xml-builder@<1.2.0: '>=1.2.0'
|
||||||
|
qs@>=6.11.1 <6.15.2: '>=6.15.2'
|
||||||
|
js-cookie@<3.0.7: '>=3.0.7'
|
||||||
|
|
||||||
|
allowBuilds:
|
||||||
|
'@nestjs/core': true
|
||||||
|
sharp: true
|
||||||
|
unrs-resolver: true
|
||||||
|
|||||||
Generated
+167
-190
@@ -31,11 +31,11 @@ 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.1",
|
"cipher 0.5.2",
|
||||||
"cpubits",
|
"cpubits",
|
||||||
"cpufeatures 0.3.0",
|
"cpufeatures 0.3.0",
|
||||||
]
|
]
|
||||||
@@ -169,7 +169,7 @@ version = "1.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -180,7 +180,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
@@ -445,9 +445,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.0"
|
version = "1.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "av-scenechange"
|
name = "av-scenechange"
|
||||||
@@ -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",
|
||||||
@@ -785,15 +785,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "built"
|
name = "built"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64"
|
checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.2"
|
version = "3.20.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byte-unit"
|
name = "byte-unit"
|
||||||
@@ -962,18 +962,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cbc"
|
name = "cbc"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "98db6aeaef0eeef2c1e3ce9a27b739218825dae116076352ac3777076aa22225"
|
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cipher 0.5.1",
|
"cipher 0.5.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[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",
|
||||||
@@ -1103,11 +1103,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cipher"
|
name = "cipher"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea"
|
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crypto-common 0.2.1",
|
"crypto-common 0.2.2",
|
||||||
"inout 0.2.2",
|
"inout 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1405,9 +1405,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
]
|
]
|
||||||
@@ -1679,7 +1679,7 @@ checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.12.0",
|
"block-buffer 0.12.0",
|
||||||
"const-oid 0.10.2",
|
"const-oid 0.10.2",
|
||||||
"crypto-common 0.2.1",
|
"crypto-common 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1709,7 +1709,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users",
|
"redox_users",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1726,9 +1726,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -1784,9 +1784,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "donutbrowser"
|
name = "donutbrowser"
|
||||||
version = "0.24.2"
|
version = "0.25.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes 0.9.0",
|
"aes 0.9.1",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-socks5",
|
"async-socks5",
|
||||||
@@ -1824,10 +1824,10 @@ dependencies = [
|
|||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"playwright",
|
"playwright",
|
||||||
"quick-xml",
|
"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",
|
||||||
@@ -1858,10 +1857,9 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"toml 0.9.12+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",
|
||||||
@@ -1962,9 +1960,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.16.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embed-resource"
|
name = "embed-resource"
|
||||||
@@ -2099,7 +2097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2213,9 +2211,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filetime"
|
name = "filetime"
|
||||||
version = "0.2.28"
|
version = "0.2.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6"
|
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -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",
|
||||||
@@ -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",
|
||||||
@@ -3554,12 +3552,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kurbo"
|
name = "kurbo"
|
||||||
version = "0.13.0"
|
version = "0.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb"
|
checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"euclid",
|
"euclid",
|
||||||
|
"polycool",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3648,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"
|
||||||
@@ -3708,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",
|
||||||
]
|
]
|
||||||
@@ -3819,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"
|
||||||
@@ -3869,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",
|
||||||
@@ -3914,15 +3894,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.19.1"
|
version = "0.19.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb"
|
checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dpi",
|
"dpi",
|
||||||
"gtk",
|
"gtk",
|
||||||
"keyboard-types",
|
"keyboard-types",
|
||||||
"libxdo",
|
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
@@ -3931,7 +3910,7 @@ dependencies = [
|
|||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4038,9 +4017,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-derive"
|
name = "num-derive"
|
||||||
@@ -4108,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",
|
||||||
@@ -4382,9 +4361,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "open"
|
name = "open"
|
||||||
version = "5.3.4"
|
version = "5.3.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd"
|
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dunce",
|
"dunce",
|
||||||
"is-wsl",
|
"is-wsl",
|
||||||
@@ -4394,9 +4373,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.79"
|
version = "0.10.80"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542"
|
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
@@ -4425,9 +4404,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-sys"
|
name = "openssl-sys"
|
||||||
version = "0.9.115"
|
version = "0.9.116"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781"
|
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -4468,7 +4447,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.45.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4660,18 +4639,18 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project"
|
name = "pin-project"
|
||||||
version = "1.1.12"
|
version = "1.1.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9"
|
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pin-project-internal",
|
"pin-project-internal",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-internal"
|
name = "pin-project-internal"
|
||||||
version = "1.1.12"
|
version = "1.1.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389"
|
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -4742,7 +4721,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"quick-xml",
|
"quick-xml 0.39.4",
|
||||||
"serde",
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
@@ -4798,6 +4777,15 @@ dependencies = [
|
|||||||
"universal-hash",
|
"universal-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "polycool"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polyval"
|
name = "polyval"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -5014,6 +5002,15 @@ name = "quick-xml"
|
|||||||
version = "0.39.4"
|
version = "0.39.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
|
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.40.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -5326,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",
|
||||||
@@ -5489,9 +5486,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rsqlite-vfs"
|
name = "rsqlite-vfs"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d"
|
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -5499,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",
|
||||||
@@ -5564,7 +5561,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5854,9 +5851,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.149"
|
version = "1.0.150"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -6114,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"
|
||||||
@@ -6228,12 +6225,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
version = "0.6.3"
|
version = "0.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6305,9 +6302,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlite-wasm-rs"
|
name = "sqlite-wasm-rs"
|
||||||
version = "0.5.3"
|
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 = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36"
|
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -6449,9 +6446,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sysinfo"
|
name = "sysinfo"
|
||||||
version = "0.39.1"
|
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 = "a4deba334e1190ba7cb498327affa11e5ece10d26a30ab2f27fcf09504b8d8b6"
|
checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -6498,9 +6495,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tao"
|
name = "tao"
|
||||||
version = "0.35.2"
|
version = "0.35.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4"
|
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"block2",
|
"block2",
|
||||||
@@ -6555,9 +6552,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tar"
|
name = "tar"
|
||||||
version = "0.4.45"
|
version = "0.4.46"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"filetime",
|
"filetime",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -6572,9 +6569,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri"
|
name = "tauri"
|
||||||
version = "2.11.1"
|
version = "2.11.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405"
|
checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -6587,6 +6584,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
|
"image",
|
||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -6600,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",
|
||||||
@@ -6613,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",
|
||||||
@@ -6623,9 +6621,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-build"
|
name = "tauri-build"
|
||||||
version = "2.6.1"
|
version = "2.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007"
|
checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"cargo_toml",
|
"cargo_toml",
|
||||||
@@ -6644,9 +6642,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-codegen"
|
name = "tauri-codegen"
|
||||||
version = "2.6.1"
|
version = "2.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528"
|
checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"brotli",
|
"brotli",
|
||||||
@@ -6671,9 +6669,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-macros"
|
name = "tauri-macros"
|
||||||
version = "2.6.1"
|
version = "2.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502"
|
checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -6685,9 +6683,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin"
|
name = "tauri-plugin"
|
||||||
version = "2.6.1"
|
version = "2.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee"
|
checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"glob",
|
"glob",
|
||||||
@@ -6874,9 +6872,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.11.1"
|
version = "2.11.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc"
|
checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cookie",
|
"cookie",
|
||||||
"dpi",
|
"dpi",
|
||||||
@@ -6899,9 +6897,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime-wry"
|
name = "tauri-runtime-wry"
|
||||||
version = "2.11.1"
|
version = "2.11.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0"
|
checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
@@ -6925,9 +6923,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-utils"
|
name = "tauri-utils"
|
||||||
version = "2.9.1"
|
version = "2.9.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec"
|
checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"brotli",
|
"brotli",
|
||||||
@@ -6979,10 +6977,10 @@ 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.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7384,9 +7382,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-http"
|
name = "tower-http"
|
||||||
version = "0.6.10"
|
version = "0.6.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -7484,28 +7482,7 @@ dependencies = [
|
|||||||
"png 0.18.1",
|
"png 0.18.1",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.61.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.61.2",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7565,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"
|
||||||
@@ -7577,7 +7554,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"memoffset",
|
"memoffset",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7825,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",
|
||||||
@@ -8092,7 +8069,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
|
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quick-xml",
|
"quick-xml 0.39.4",
|
||||||
"quote",
|
"quote",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -8235,7 +8212,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8761,7 +8738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9064,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",
|
||||||
@@ -9099,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",
|
||||||
@@ -9125,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",
|
||||||
@@ -9145,9 +9122,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerofrom"
|
name = "zerofrom"
|
||||||
version = "0.1.7"
|
version = "0.1.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
|
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerofrom-derive",
|
"zerofrom-derive",
|
||||||
]
|
]
|
||||||
@@ -9332,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",
|
||||||
@@ -9346,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",
|
||||||
@@ -9359,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",
|
||||||
|
|||||||
+8
-14
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "donutbrowser"
|
name = "donutbrowser"
|
||||||
version = "0.24.2"
|
version = "0.25.3"
|
||||||
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,22 +94,20 @@ 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 = "0.9"
|
toml = "1.1"
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
regex-lite = "0.1"
|
regex-lite = "0.1"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
maxminddb = "0.28"
|
maxminddb = "0.28"
|
||||||
quick-xml = { version = "0.39", features = ["serialize"] }
|
quick-xml = { version = "0.40", features = ["serialize"] }
|
||||||
|
|
||||||
# VPN support
|
# VPN support
|
||||||
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"
|
|
||||||
|
|
||||||
|
|||||||
+151
-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;
|
||||||
@@ -87,6 +86,8 @@ pub struct UpdateProfileRequest {
|
|||||||
pub tags: Option<Vec<String>>,
|
pub tags: Option<Vec<String>>,
|
||||||
pub extension_group_id: Option<String>,
|
pub extension_group_id: Option<String>,
|
||||||
pub proxy_bypass_rules: Option<Vec<String>>,
|
pub proxy_bypass_rules: Option<Vec<String>>,
|
||||||
|
/// One of "Disabled", "Regular", "Encrypted".
|
||||||
|
pub sync_mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -215,6 +216,20 @@ struct OpenUrlRequest {
|
|||||||
url: String,
|
url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
struct ImportCookiesRequest {
|
||||||
|
/// Raw cookie file content. Format is auto-detected: a JSON array
|
||||||
|
/// (Puppeteer / EditThisCookie style) or a Netscape `cookies.txt`.
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
struct ImportCookiesResponse {
|
||||||
|
cookies_imported: usize,
|
||||||
|
cookies_replaced: usize,
|
||||||
|
errors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(
|
paths(
|
||||||
@@ -226,6 +241,7 @@ struct OpenUrlRequest {
|
|||||||
run_profile,
|
run_profile,
|
||||||
open_url_in_profile,
|
open_url_in_profile,
|
||||||
kill_profile,
|
kill_profile,
|
||||||
|
import_profile_cookies,
|
||||||
get_groups,
|
get_groups,
|
||||||
get_group,
|
get_group,
|
||||||
create_group,
|
create_group,
|
||||||
@@ -268,6 +284,8 @@ struct OpenUrlRequest {
|
|||||||
RunProfileResponse,
|
RunProfileResponse,
|
||||||
RunProfileRequest,
|
RunProfileRequest,
|
||||||
OpenUrlRequest,
|
OpenUrlRequest,
|
||||||
|
ImportCookiesRequest,
|
||||||
|
ImportCookiesResponse,
|
||||||
ProxySettings,
|
ProxySettings,
|
||||||
)),
|
)),
|
||||||
tags(
|
tags(
|
||||||
@@ -277,6 +295,7 @@ struct OpenUrlRequest {
|
|||||||
(name = "proxies", description = "Proxy management endpoints"),
|
(name = "proxies", description = "Proxy management endpoints"),
|
||||||
(name = "vpns", description = "VPN management endpoints"),
|
(name = "vpns", description = "VPN management endpoints"),
|
||||||
(name = "browsers", description = "Browser management endpoints"),
|
(name = "browsers", description = "Browser management endpoints"),
|
||||||
|
(name = "cookies", description = "Cookie management endpoints"),
|
||||||
),
|
),
|
||||||
modifiers(&SecurityAddon),
|
modifiers(&SecurityAddon),
|
||||||
)]
|
)]
|
||||||
@@ -363,6 +382,7 @@ impl ApiServer {
|
|||||||
.routes(routes!(run_profile))
|
.routes(routes!(run_profile))
|
||||||
.routes(routes!(open_url_in_profile))
|
.routes(routes!(open_url_in_profile))
|
||||||
.routes(routes!(kill_profile))
|
.routes(routes!(kill_profile))
|
||||||
|
.routes(routes!(import_profile_cookies))
|
||||||
.routes(routes!(get_groups, create_group))
|
.routes(routes!(get_groups, create_group))
|
||||||
.routes(routes!(get_group, update_group, delete_group))
|
.routes(routes!(get_group, update_group, delete_group))
|
||||||
.routes(routes!(get_tags))
|
.routes(routes!(get_tags))
|
||||||
@@ -391,16 +411,14 @@ 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 api_for_v1 = api.clone();
|
||||||
let ws_state = WsState::new();
|
|
||||||
let ws_routes = Router::new()
|
|
||||||
.route("/events", get(ws_handler))
|
|
||||||
.with_state(ws_state);
|
|
||||||
|
|
||||||
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(
|
||||||
|
"/v1/openapi.json",
|
||||||
|
get(move || async move { Json(api_for_v1) }),
|
||||||
|
)
|
||||||
// Outermost layer: logs every request so customer reports show what
|
// Outermost layer: logs every request so customer reports show what
|
||||||
// their automation is actually calling, what the response status was,
|
// their automation is actually calling, what the response status was,
|
||||||
// and how long it took. Never logs request bodies or auth headers.
|
// and how long it took. Never logs request bodies or auth headers.
|
||||||
@@ -568,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,
|
||||||
@@ -598,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
|
||||||
@@ -655,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
|
||||||
@@ -709,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(
|
||||||
@@ -758,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,
|
||||||
@@ -866,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) => {
|
||||||
@@ -929,6 +966,15 @@ async fn update_profile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(sync_mode) = request.sync_mode {
|
||||||
|
if crate::sync::set_profile_sync_mode(state.app_handle.clone(), id.clone(), sync_mode)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Return updated profile
|
// Return updated profile
|
||||||
get_profile(Path(id), State(state)).await
|
get_profile(Path(id), State(state)).await
|
||||||
}
|
}
|
||||||
@@ -1715,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
|
||||||
{
|
{
|
||||||
@@ -1785,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")
|
||||||
),
|
),
|
||||||
@@ -1797,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()
|
||||||
@@ -1818,6 +1876,77 @@ async fn kill_profile(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/v1/profiles/{id}/cookies/import",
|
||||||
|
params(
|
||||||
|
("id" = String, Path, description = "Profile ID")
|
||||||
|
),
|
||||||
|
request_body = ImportCookiesRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Cookies imported successfully", body = ImportCookiesResponse),
|
||||||
|
(status = 400, description = "Invalid cookie file or unsupported browser"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 404, description = "Profile not found"),
|
||||||
|
(status = 409, description = "Browser is currently running"),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("bearer_auth" = [])
|
||||||
|
),
|
||||||
|
tag = "cookies"
|
||||||
|
)]
|
||||||
|
async fn import_profile_cookies(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
State(state): State<ApiServerState>,
|
||||||
|
Json(request): Json<ImportCookiesRequest>,
|
||||||
|
) -> Result<Json<ImportCookiesResponse>, StatusCode> {
|
||||||
|
let profile_manager = ProfileManager::instance();
|
||||||
|
let profiles = profile_manager
|
||||||
|
.list_profiles()
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
if !profiles.iter().any(|p| p.id.to_string() == id) {
|
||||||
|
return Err(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
match crate::cookie_manager::CookieManager::import_cookies(
|
||||||
|
&state.app_handle,
|
||||||
|
&id,
|
||||||
|
&request.content,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => {
|
||||||
|
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||||
|
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
|
||||||
|
if profile.is_sync_enabled() {
|
||||||
|
let pid = id.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
scheduler.queue_profile_sync(pid).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Json(ImportCookiesResponse {
|
||||||
|
cookies_imported: result.cookies_imported,
|
||||||
|
cookies_replaced: result.cookies_replaced,
|
||||||
|
errors: result.errors,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let msg = e.to_lowercase();
|
||||||
|
if msg.contains("running") {
|
||||||
|
Err(StatusCode::CONFLICT)
|
||||||
|
} else if msg.contains("no valid cookies") || msg.contains("unsupported browser") {
|
||||||
|
Err(StatusCode::BAD_REQUEST)
|
||||||
|
} else {
|
||||||
|
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// API Handler - Download Browser
|
// API Handler - Download Browser
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -376,11 +376,12 @@ impl CamoufoxConfigBuilder {
|
|||||||
(config, target_os)
|
(config, target_os)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add random window history length
|
// Note: we used to spoof `window.history.length` to a random value in
|
||||||
config.insert(
|
// [1, 5] here. Newer Camoufox builds clamp the docShell session history
|
||||||
"window.history.length".to_string(),
|
// to this value, which disables the toolbar back/forward buttons when
|
||||||
serde_json::json!(rng.random_range(1..=5)),
|
// the spoof rolls a small number. The fingerprint value drifts on every
|
||||||
);
|
// user navigation anyway, so a constant spoof is detectable and not
|
||||||
|
// worth the broken navigation UX.
|
||||||
|
|
||||||
// Add fonts
|
// Add fonts
|
||||||
if !self.custom_fonts_only {
|
if !self.custom_fonts_only {
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -222,10 +224,16 @@ impl CamoufoxManager {
|
|||||||
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?;
|
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?;
|
||||||
|
|
||||||
// Parse the fingerprint config JSON
|
// Parse the fingerprint config JSON
|
||||||
let fingerprint_config: HashMap<String, serde_json::Value> =
|
let mut fingerprint_config: HashMap<String, serde_json::Value> =
|
||||||
serde_json::from_str(&custom_config)
|
serde_json::from_str(&custom_config)
|
||||||
.map_err(|e| format!("Failed to parse fingerprint config: {e}"))?;
|
.map_err(|e| format!("Failed to parse fingerprint config: {e}"))?;
|
||||||
|
|
||||||
|
// Strip `window.history.length` even when present in a previously-saved
|
||||||
|
// fingerprint. Newer Camoufox clamps the docShell session history to the
|
||||||
|
// spoofed value, which disables the toolbar back/forward buttons. See
|
||||||
|
// the matching note in camoufox/config.rs.
|
||||||
|
fingerprint_config.remove("window.history.length");
|
||||||
|
|
||||||
// Convert to environment variables using CAMOU_CONFIG chunking
|
// Convert to environment variables using CAMOU_CONFIG chunking
|
||||||
let env_vars = crate::camoufox::env_vars::config_to_env_vars(&fingerprint_config)
|
let env_vars = crate::camoufox::env_vars::config_to_env_vars(&fingerprint_config)
|
||||||
.map_err(|e| format!("Failed to convert config to env vars: {e}"))?;
|
.map_err(|e| format!("Failed to convert config to env vars: {e}"))?;
|
||||||
@@ -243,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
|
||||||
@@ -264,13 +275,33 @@ impl CamoufoxManager {
|
|||||||
args
|
args
|
||||||
);
|
);
|
||||||
|
|
||||||
// Spawn the browser process
|
// Spawn the browser process. Camoufox prints NSS/PSM and proxy failures
|
||||||
|
// to stderr (e.g. cert errors, CONNECT failures) and the user otherwise
|
||||||
|
// sees only an opaque "Secure Connection Failed" page — capture stderr
|
||||||
|
// to a per-launch file so diagnostics survive without a TTY.
|
||||||
|
let stderr_log_path = std::env::temp_dir().join(format!("camoufox-stderr-{}.log", profile.id));
|
||||||
let mut command = TokioCommand::new(&executable_path);
|
let mut command = TokioCommand::new(&executable_path);
|
||||||
command
|
command
|
||||||
.args(&args)
|
.args(&args)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null());
|
||||||
.stderr(Stdio::null());
|
|
||||||
|
match std::fs::File::create(&stderr_log_path) {
|
||||||
|
Ok(file) => {
|
||||||
|
log::info!(
|
||||||
|
"Camoufox stderr will be logged to: {}",
|
||||||
|
stderr_log_path.display()
|
||||||
|
);
|
||||||
|
command.stderr(Stdio::from(file));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to open Camoufox stderr log {}: {e}",
|
||||||
|
stderr_log_path.display()
|
||||||
|
);
|
||||||
|
command.stderr(Stdio::null());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add environment variables
|
// Add environment variables
|
||||||
for (key, value) in &env_vars {
|
for (key, value) in &env_vars {
|
||||||
@@ -287,7 +318,7 @@ impl CamoufoxManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let child = command
|
let mut child = command
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| format!("Failed to spawn Camoufox process: {e}"))?;
|
.map_err(|e| format!("Failed to spawn Camoufox process: {e}"))?;
|
||||||
|
|
||||||
@@ -296,6 +327,34 @@ impl CamoufoxManager {
|
|||||||
|
|
||||||
log::info!("Camoufox launched with PID: {:?}", process_id);
|
log::info!("Camoufox launched with PID: {:?}", process_id);
|
||||||
|
|
||||||
|
// Watch the child so its exit status (signal / non-zero code) lands in
|
||||||
|
// the log. Without this, all we see is "PID X is no longer running" via
|
||||||
|
// the periodic sysinfo poll, with no clue why it died.
|
||||||
|
let watch_profile_path = profile_path.to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match child.wait().await {
|
||||||
|
Ok(status) => {
|
||||||
|
if status.success() {
|
||||||
|
log::info!(
|
||||||
|
"Camoufox PID {:?} for {} exited cleanly (status=0)",
|
||||||
|
process_id,
|
||||||
|
watch_profile_path
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"Camoufox PID {:?} for {} exited abnormally: {}",
|
||||||
|
process_id,
|
||||||
|
watch_profile_path,
|
||||||
|
status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to await Camoufox PID {:?} exit: {}", process_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Store the instance
|
// Store the instance
|
||||||
let instance = CamoufoxInstance {
|
let instance = CamoufoxInstance {
|
||||||
id: instance_id.clone(),
|
id: instance_id.clone(),
|
||||||
@@ -557,28 +616,28 @@ impl CamoufoxManager {
|
|||||||
|
|
||||||
for (id, instance) in inner.instances.iter() {
|
for (id, instance) in inner.instances.iter() {
|
||||||
if let Some(process_id) = instance.process_id {
|
if let Some(process_id) = instance.process_id {
|
||||||
// Check if the process is still alive
|
|
||||||
if !self.is_server_running(process_id).await {
|
if !self.is_server_running(process_id).await {
|
||||||
// Process is dead
|
log::info!(
|
||||||
// Camoufox instance is no longer running
|
"Camoufox instance {} (PID {}) is no longer running; profile_path={:?}",
|
||||||
|
id,
|
||||||
|
process_id,
|
||||||
|
instance.profile_path
|
||||||
|
);
|
||||||
dead_instances.push(id.clone());
|
dead_instances.push(id.clone());
|
||||||
instances_to_remove.push(id.clone());
|
instances_to_remove.push(id.clone());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No process_id means it's likely a dead instance
|
log::info!("Camoufox instance {} has no PID, marking as dead", id);
|
||||||
// Camoufox instance has no PID, marking as dead
|
|
||||||
dead_instances.push(id.clone());
|
dead_instances.push(id.clone());
|
||||||
instances_to_remove.push(id.clone());
|
instances_to_remove.push(id.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove dead instances
|
|
||||||
if !instances_to_remove.is_empty() {
|
if !instances_to_remove.is_empty() {
|
||||||
let mut inner = self.inner.lock().await;
|
let mut inner = self.inner.lock().await;
|
||||||
for id in &instances_to_remove {
|
for id in &instances_to_remove {
|
||||||
inner.instances.remove(id);
|
inner.instances.remove(id);
|
||||||
// Removed dead Camoufox instance
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,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,
|
||||||
@@ -619,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
|
||||||
@@ -662,54 +723,98 @@ impl CamoufoxManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write explicit proxy prefs to user.js so Firefox always uses the local
|
// Patch user.js with Camoufox-specific overrides on every launch. This
|
||||||
// donut-proxy and never falls back to stale proxy settings baked into prefs.js
|
// always runs (not gated on the proxy being set) because Camoufox's
|
||||||
// from a previous session. user.js values override prefs.js on every launch.
|
// bundled camoufox.cfg ships defaults that break basic browser features
|
||||||
if let Some(proxy_str) = &config.proxy {
|
// and we need to override them per-profile.
|
||||||
|
{
|
||||||
let user_js_path = profile_path.join("user.js");
|
let user_js_path = profile_path.join("user.js");
|
||||||
let mut prefs = String::new();
|
let mut prefs = String::new();
|
||||||
|
|
||||||
// Preserve existing user.js content (ephemeral prefs, etc.)
|
// Preserve existing user.js lines, but strip any keys we're about to
|
||||||
|
// re-emit so they never duplicate.
|
||||||
|
let managed_keys = [
|
||||||
|
"network.proxy.",
|
||||||
|
"network.http.http3.enable",
|
||||||
|
"network.http.http3.enabled",
|
||||||
|
"xpinstall.signatures.required",
|
||||||
|
"extensions.startupScanScopes",
|
||||||
|
"browser.sessionhistory.max_entries",
|
||||||
|
"browser.sessionhistory.max_total_viewers",
|
||||||
|
];
|
||||||
if let Ok(existing) = std::fs::read_to_string(&user_js_path) {
|
if let Ok(existing) = std::fs::read_to_string(&user_js_path) {
|
||||||
// Strip old proxy prefs so we don't duplicate
|
|
||||||
for line in existing.lines() {
|
for line in existing.lines() {
|
||||||
if !line.contains("network.proxy.") {
|
if !managed_keys.iter().any(|k| line.contains(k)) {
|
||||||
prefs.push_str(line);
|
prefs.push_str(line);
|
||||||
prefs.push('\n');
|
prefs.push('\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(parsed) = url::Url::parse(proxy_str) {
|
// Camoufox's bundled camoufox.cfg sets these to 0, which makes
|
||||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
// docShell remember zero prior pages and leaves the toolbar
|
||||||
let port = parsed.port().unwrap_or(8080);
|
// back/forward buttons permanently disabled no matter how much
|
||||||
let scheme = parsed.scheme();
|
// the user navigates. Restore Firefox defaults.
|
||||||
|
prefs.push_str(
|
||||||
|
"user_pref(\"browser.sessionhistory.max_entries\", 50);\n\
|
||||||
|
user_pref(\"browser.sessionhistory.max_total_viewers\", -1);\n",
|
||||||
|
);
|
||||||
|
|
||||||
if scheme == "socks5" || scheme == "socks4" {
|
// Required for sideloaded extensions:
|
||||||
prefs.push_str(&format!(
|
// - signatures.required=false accepts unsigned .xpi (Camoufox is built
|
||||||
"user_pref(\"network.proxy.type\", 1);\n\
|
// without MOZ_REQUIRE_SIGNING so this is honored).
|
||||||
user_pref(\"network.proxy.socks\", \"{host}\");\n\
|
// - startupScanScopes=1 rescans SCOPE_PROFILE on each launch so newly
|
||||||
user_pref(\"network.proxy.socks_port\", {port});\n\
|
// dropped .xpi files in <profile>/extensions/ get registered.
|
||||||
user_pref(\"network.proxy.socks_version\", {});\n\
|
prefs.push_str(
|
||||||
user_pref(\"network.proxy.socks_remote_dns\", true);\n",
|
"user_pref(\"xpinstall.signatures.required\", false);\n\
|
||||||
if scheme == "socks5" { 5 } else { 4 }
|
user_pref(\"extensions.startupScanScopes\", 1);\n",
|
||||||
));
|
);
|
||||||
} else {
|
|
||||||
// HTTP/HTTPS proxy
|
|
||||||
prefs.push_str(&format!(
|
|
||||||
"user_pref(\"network.proxy.type\", 1);\n\
|
|
||||||
user_pref(\"network.proxy.http\", \"{host}\");\n\
|
|
||||||
user_pref(\"network.proxy.http_port\", {port});\n\
|
|
||||||
user_pref(\"network.proxy.ssl\", \"{host}\");\n\
|
|
||||||
user_pref(\"network.proxy.ssl_port\", {port});\n\
|
|
||||||
user_pref(\"network.proxy.no_proxies_on\", \"\");\n"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = std::fs::write(&user_js_path, prefs) {
|
// Disable HTTP/3 / QUIC. Camoufox always sits behind the local
|
||||||
log::warn!("Failed to write proxy prefs to user.js: {e}");
|
// donut-proxy, and Firefox-150's QUIC stack bypasses configured HTTP
|
||||||
|
// proxies and goes direct UDP to the remote host. With an upstream
|
||||||
|
// proxy that's the only allowed egress, that traffic silently fails
|
||||||
|
// and pages won't load. (Chromium suppresses QUIC under a proxy on
|
||||||
|
// its own, so Wayfern doesn't need the equivalent toggle.) Both
|
||||||
|
// pref names are emitted because they've been renamed across FF
|
||||||
|
// versions and either could be the active one at runtime.
|
||||||
|
prefs.push_str(
|
||||||
|
"user_pref(\"network.http.http3.enable\", false);\n\
|
||||||
|
user_pref(\"network.http.http3.enabled\", false);\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(proxy_str) = &config.proxy {
|
||||||
|
if let Ok(parsed) = url::Url::parse(proxy_str) {
|
||||||
|
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||||
|
let port = parsed.port().unwrap_or(8080);
|
||||||
|
let scheme = parsed.scheme();
|
||||||
|
|
||||||
|
if scheme == "socks5" || scheme == "socks4" {
|
||||||
|
prefs.push_str(&format!(
|
||||||
|
"user_pref(\"network.proxy.type\", 1);\n\
|
||||||
|
user_pref(\"network.proxy.socks\", \"{host}\");\n\
|
||||||
|
user_pref(\"network.proxy.socks_port\", {port});\n\
|
||||||
|
user_pref(\"network.proxy.socks_version\", {});\n\
|
||||||
|
user_pref(\"network.proxy.socks_remote_dns\", true);\n",
|
||||||
|
if scheme == "socks5" { 5 } else { 4 }
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
// HTTP/HTTPS proxy
|
||||||
|
prefs.push_str(&format!(
|
||||||
|
"user_pref(\"network.proxy.type\", 1);\n\
|
||||||
|
user_pref(\"network.proxy.http\", \"{host}\");\n\
|
||||||
|
user_pref(\"network.proxy.http_port\", {port});\n\
|
||||||
|
user_pref(\"network.proxy.ssl\", \"{host}\");\n\
|
||||||
|
user_pref(\"network.proxy.ssl_port\", {port});\n\
|
||||||
|
user_pref(\"network.proxy.no_proxies_on\", \"\");\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Err(e) = std::fs::write(&user_js_path, prefs) {
|
||||||
|
log::warn!("Failed to write user.js: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self
|
self
|
||||||
@@ -719,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
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ pub struct Extension {
|
|||||||
pub author: Option<String>,
|
pub author: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub homepage_url: Option<String>,
|
pub homepage_url: Option<String>,
|
||||||
|
/// Firefox extension ID from `browser_specific_settings.gecko.id` (or
|
||||||
|
/// `applications.gecko.id` in old manifests). Firefox refuses to load a
|
||||||
|
/// sideloaded .xpi unless the filename matches this value.
|
||||||
|
#[serde(default)]
|
||||||
|
pub gecko_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -157,6 +162,32 @@ fn extract_manifest_metadata(
|
|||||||
(name, version, description, author, homepage_url)
|
(name, version, description, author, homepage_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read `browser_specific_settings.gecko.id` (or the legacy
|
||||||
|
/// `applications.gecko.id`) from the extension's manifest.json. Firefox uses
|
||||||
|
/// this value as the canonical add-on ID; sideloaded .xpi files must be named
|
||||||
|
/// `<gecko_id>.xpi` to be picked up.
|
||||||
|
fn extract_gecko_id(file_data: &[u8], file_type: &str) -> Option<String> {
|
||||||
|
let zip_start = if file_type == "crx" {
|
||||||
|
find_zip_start(file_data)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let cursor = std::io::Cursor::new(&file_data[zip_start..]);
|
||||||
|
let mut archive = zip::ZipArchive::new(cursor).ok()?;
|
||||||
|
let mut manifest_content = String::new();
|
||||||
|
std::io::Read::read_to_string(
|
||||||
|
&mut archive.by_name("manifest.json").ok()?,
|
||||||
|
&mut manifest_content,
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
let manifest: serde_json::Value = serde_json::from_str(&manifest_content).ok()?;
|
||||||
|
manifest
|
||||||
|
.pointer("/browser_specific_settings/gecko/id")
|
||||||
|
.or_else(|| manifest.pointer("/applications/gecko/id"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_icon_from_archive(file_data: &[u8], file_type: &str) -> Option<(Vec<u8>, String)> {
|
fn extract_icon_from_archive(file_data: &[u8], file_type: &str) -> Option<(Vec<u8>, String)> {
|
||||||
let zip_start = if file_type == "crx" {
|
let zip_start = if file_type == "crx" {
|
||||||
find_zip_start(file_data)
|
find_zip_start(file_data)
|
||||||
@@ -285,6 +316,7 @@ impl ExtensionManager {
|
|||||||
name
|
name
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let gecko_id = extract_gecko_id(&file_data, &file_type);
|
||||||
let ext = Extension {
|
let ext = Extension {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
name: final_name,
|
name: final_name,
|
||||||
@@ -299,6 +331,7 @@ impl ExtensionManager {
|
|||||||
description,
|
description,
|
||||||
author,
|
author,
|
||||||
homepage_url,
|
homepage_url,
|
||||||
|
gecko_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
let file_dir = self.get_file_dir(&ext.id);
|
let file_dir = self.get_file_dir(&ext.id);
|
||||||
@@ -415,6 +448,7 @@ impl ExtensionManager {
|
|||||||
ext.name = mn;
|
ext.name = mn;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ext.gecko_id = extract_gecko_id(&data, &new_file_type);
|
||||||
|
|
||||||
if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&data, &new_file_type) {
|
if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&data, &new_file_type) {
|
||||||
let icon_path = self.get_extension_dir(id).join(format!("icon.{icon_ext}"));
|
let icon_path = self.get_extension_dir(id).join(format!("icon.{icon_ext}"));
|
||||||
@@ -893,24 +927,33 @@ impl ExtensionManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let src_file = self.get_file_dir(ext_id).join(&ext.file_name);
|
let src_file = self.get_file_dir(ext_id).join(&ext.file_name);
|
||||||
if src_file.exists() {
|
if !src_file.exists() {
|
||||||
// Firefox expects .xpi files in extensions dir
|
continue;
|
||||||
let dest_name = if ext.file_type == "zip" {
|
|
||||||
format!(
|
|
||||||
"{}.xpi",
|
|
||||||
ext
|
|
||||||
.file_name
|
|
||||||
.rsplit('.')
|
|
||||||
.next_back()
|
|
||||||
.unwrap_or(&ext.file_name)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ext.file_name.clone()
|
|
||||||
};
|
|
||||||
let dest = extensions_dir.join(&dest_name);
|
|
||||||
fs::copy(&src_file, &dest)?;
|
|
||||||
extension_paths.push(dest.to_string_lossy().to_string());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Firefox/Camoufox only loads sideloaded .xpi files whose filename
|
||||||
|
// matches `browser_specific_settings.gecko.id` from the manifest.
|
||||||
|
// Prefer the cached value; fall back to reading the manifest now
|
||||||
|
// for extensions added before the field existed.
|
||||||
|
let gecko_id = if let Some(ref id) = ext.gecko_id {
|
||||||
|
Some(id.clone())
|
||||||
|
} else if let Ok(data) = fs::read(&src_file) {
|
||||||
|
extract_gecko_id(&data, &ext.file_type)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(gecko_id) = gecko_id else {
|
||||||
|
log::warn!(
|
||||||
|
"Skipping Firefox extension '{}': could not determine gecko id from manifest.json",
|
||||||
|
ext.name
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let dest = extensions_dir.join(format!("{gecko_id}.xpi"));
|
||||||
|
fs::copy(&src_file, &dest)?;
|
||||||
|
extension_paths.push(dest.to_string_lossy().to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1022,30 +1065,49 @@ impl ExtensionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.version.is_none() && ext.description.is_none() {
|
let needs_meta_backfill = ext.version.is_none() && ext.description.is_none();
|
||||||
|
let needs_gecko_backfill =
|
||||||
|
ext.gecko_id.is_none() && ext.browser_compatibility.iter().any(|b| b == "firefox");
|
||||||
|
|
||||||
|
if needs_meta_backfill || needs_gecko_backfill {
|
||||||
let file_path = file_dir.join(&ext.file_name);
|
let file_path = file_dir.join(&ext.file_name);
|
||||||
if let Ok(file_data) = fs::read(&file_path) {
|
if let Ok(file_data) = fs::read(&file_path) {
|
||||||
let (manifest_name, version, description, author, homepage_url) =
|
let mut updated_ext = ext.clone();
|
||||||
extract_manifest_metadata(&file_data, &ext.file_type);
|
let mut changed = false;
|
||||||
if version.is_some()
|
|
||||||
|| description.is_some()
|
if needs_meta_backfill {
|
||||||
|| author.is_some()
|
let (manifest_name, version, description, author, homepage_url) =
|
||||||
|| homepage_url.is_some()
|
extract_manifest_metadata(&file_data, &ext.file_type);
|
||||||
|| manifest_name.is_some()
|
if version.is_some()
|
||||||
{
|
|| description.is_some()
|
||||||
let mut updated_ext = ext.clone();
|
|| author.is_some()
|
||||||
if let Some(v) = version {
|
|| homepage_url.is_some()
|
||||||
updated_ext.version = Some(v);
|
|| manifest_name.is_some()
|
||||||
|
{
|
||||||
|
if let Some(v) = version {
|
||||||
|
updated_ext.version = Some(v);
|
||||||
|
}
|
||||||
|
if let Some(d) = description {
|
||||||
|
updated_ext.description = Some(d);
|
||||||
|
}
|
||||||
|
if let Some(a) = author {
|
||||||
|
updated_ext.author = Some(a);
|
||||||
|
}
|
||||||
|
if let Some(h) = homepage_url {
|
||||||
|
updated_ext.homepage_url = Some(h);
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
if let Some(d) = description {
|
}
|
||||||
updated_ext.description = Some(d);
|
|
||||||
}
|
if needs_gecko_backfill {
|
||||||
if let Some(a) = author {
|
if let Some(gid) = extract_gecko_id(&file_data, &ext.file_type) {
|
||||||
updated_ext.author = Some(a);
|
updated_ext.gecko_id = Some(gid);
|
||||||
}
|
changed = true;
|
||||||
if let Some(h) = homepage_url {
|
|
||||||
updated_ext.homepage_url = Some(h);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
let metadata_path = self.get_metadata_path(&ext.id);
|
let metadata_path = self.get_metadata_path(&ext.id);
|
||||||
if let Ok(json) = serde_json::to_string_pretty(&updated_ext) {
|
if let Ok(json) = serde_json::to_string_pretty(&updated_ext) {
|
||||||
let _ = fs::write(metadata_path, json);
|
let _ = fs::write(metadata_path, json);
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
+227
-27
@@ -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,14 +93,14 @@ 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::{
|
||||||
check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities,
|
cancel_profile_sync, check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities,
|
||||||
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile,
|
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile,
|
||||||
is_vpn_in_use_by_synced_profile, request_profile_sync, rollover_encryption_for_all_entities,
|
is_vpn_in_use_by_synced_profile, request_profile_sync, rollover_encryption_for_all_entities,
|
||||||
set_e2e_password, set_extension_group_sync_enabled, set_extension_sync_enabled,
|
set_e2e_password, set_extension_group_sync_enabled, set_extension_sync_enabled,
|
||||||
@@ -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,
|
||||||
@@ -2057,6 +2257,7 @@ pub fn run() {
|
|||||||
get_sync_settings,
|
get_sync_settings,
|
||||||
save_sync_settings,
|
save_sync_settings,
|
||||||
set_profile_sync_mode,
|
set_profile_sync_mode,
|
||||||
|
cancel_profile_sync,
|
||||||
request_profile_sync,
|
request_profile_sync,
|
||||||
set_proxy_sync_enabled,
|
set_proxy_sync_enabled,
|
||||||
set_group_sync_enabled,
|
set_group_sync_enabled,
|
||||||
@@ -2103,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,
|
||||||
|
|||||||
+533
-20
@@ -33,6 +33,48 @@ pub struct McpTool {
|
|||||||
pub input_schema: serde_json::Value,
|
pub input_schema: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// JavaScript executed in the target page to enumerate visible interactive
|
||||||
|
/// elements. Returns a JSON string `{elements, count, truncated}` where
|
||||||
|
/// `elements` is the newline-joined labeled list. Live references are stashed
|
||||||
|
/// on `window.__donut_interactive` so subsequent `click_by_index` /
|
||||||
|
/// `type_by_index` calls can resolve `index → Element` without round-tripping
|
||||||
|
/// a selector. `__MAX_CHARS__` is substituted at call time.
|
||||||
|
const INTERACTIVE_ELEMENTS_JS: &str = r#"(() => {
|
||||||
|
const SELECTORS = 'a, button, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="tab"], [role="menuitem"], [role="combobox"], [role="option"], [contenteditable=""], [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
|
||||||
|
const ATTRS = ['type','name','id','role','aria-label','aria-checked','aria-expanded','placeholder','title','value','href','alt'];
|
||||||
|
const MAX_CHARS = __MAX_CHARS__;
|
||||||
|
const interactive = [];
|
||||||
|
const lines = [];
|
||||||
|
let truncated = false;
|
||||||
|
let total = 0;
|
||||||
|
const nodes = document.querySelectorAll(SELECTORS);
|
||||||
|
for (const el of nodes) {
|
||||||
|
if (el.disabled) continue;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
if (r.width <= 0 || r.height <= 0) continue;
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') continue;
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
const parts = [];
|
||||||
|
for (const a of ATTRS) {
|
||||||
|
const v = el.getAttribute(a);
|
||||||
|
if (v) parts.push(a + '="' + String(v).slice(0,100).replace(/"/g,'\\"') + '"');
|
||||||
|
}
|
||||||
|
let text = '';
|
||||||
|
if (!['INPUT','TEXTAREA','SELECT'].includes(el.tagName)) {
|
||||||
|
text = (el.innerText || el.textContent || '').trim().replace(/\s+/g,' ').slice(0,100);
|
||||||
|
}
|
||||||
|
const idx = interactive.length;
|
||||||
|
const line = '[' + idx + ']<' + tag + (parts.length ? ' ' + parts.join(' ') : '') + '>' + text + '</' + tag + '>';
|
||||||
|
if (total + line.length + 1 > MAX_CHARS) { truncated = true; break; }
|
||||||
|
total += line.length + 1;
|
||||||
|
interactive.push(el);
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
window.__donut_interactive = interactive;
|
||||||
|
return JSON.stringify({ elements: lines.join('\n'), count: interactive.length, truncated: truncated });
|
||||||
|
})()"#;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct McpRequest {
|
pub struct McpRequest {
|
||||||
@@ -466,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": {
|
||||||
@@ -488,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": {
|
||||||
@@ -1103,6 +1145,25 @@ impl McpServer {
|
|||||||
"required": ["profile_id"]
|
"required": ["profile_id"]
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
// Cookie management tools
|
||||||
|
McpTool {
|
||||||
|
name: "import_profile_cookies".to_string(),
|
||||||
|
description: "Import cookies into a Wayfern or Camoufox profile from a JSON array (Puppeteer / EditThisCookie format) or a Netscape cookies.txt. Format is auto-detected. The browser must not be running.".to_string(),
|
||||||
|
input_schema: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The UUID of the target profile"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Raw cookie file content (JSON array or Netscape cookies.txt)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["profile_id", "content"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
// Team lock tools
|
// Team lock tools
|
||||||
McpTool {
|
McpTool {
|
||||||
name: "get_team_locks".to_string(),
|
name: "get_team_locks".to_string(),
|
||||||
@@ -1354,6 +1415,76 @@ impl McpServer {
|
|||||||
"required": ["profile_id"]
|
"required": ["profile_id"]
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
McpTool {
|
||||||
|
name: "get_interactive_elements".to_string(),
|
||||||
|
description: "Enumerate visible interactive elements on the page (buttons, links, inputs, etc.) as a compact indexed list. The returned indices are stable for the current page and can be used with click_by_index and type_by_index instead of guessing CSS selectors. Call this before click_by_index / type_by_index, and re-call after any navigation or major DOM change. Far cheaper in tokens than get_page_content for agentic browsing.".to_string(),
|
||||||
|
input_schema: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The UUID of the running profile"
|
||||||
|
},
|
||||||
|
"max_chars": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Cap on the serialized output length (default: 40000). The response carries a `truncated` flag if the list was cut off — narrow the viewport or scroll if you need elements past the cutoff."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["profile_id"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
McpTool {
|
||||||
|
name: "click_by_index".to_string(),
|
||||||
|
description: "Click the element at the given index from the last get_interactive_elements call. Indices are valid until the next navigation. If the click triggers navigation, waits for the new page to load before returning.".to_string(),
|
||||||
|
input_schema: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The UUID of the running profile"
|
||||||
|
},
|
||||||
|
"index": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Zero-based index from the last get_interactive_elements response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["profile_id", "index"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
McpTool {
|
||||||
|
name: "type_by_index".to_string(),
|
||||||
|
description: "Focus the element at the given index from the last get_interactive_elements call and type text into it. Same human-like-typing defaults as type_text; only set instant=true when you're sure the target lacks bot detection.".to_string(),
|
||||||
|
input_schema: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The UUID of the running profile"
|
||||||
|
},
|
||||||
|
"index": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Zero-based index from the last get_interactive_elements response"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Text to type into the element"
|
||||||
|
},
|
||||||
|
"clear_first": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Clear the input before typing (default: true)"
|
||||||
|
},
|
||||||
|
"instant": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Paste all text at once instead of human typing. WARNING: only use on targets without bot detection."
|
||||||
|
},
|
||||||
|
"wpm": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Target words per minute for human typing (default: 80)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["profile_id", "index", "text"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1540,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)
|
||||||
@@ -1562,6 +1699,8 @@ impl McpServer {
|
|||||||
.handle_assign_extension_group_to_profile(arguments)
|
.handle_assign_extension_group_to_profile(arguments)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
// Cookie management
|
||||||
|
"import_profile_cookies" => self.handle_import_profile_cookies(arguments).await,
|
||||||
// Team lock tools
|
// Team lock tools
|
||||||
"get_team_locks" => self.handle_get_team_locks().await,
|
"get_team_locks" => self.handle_get_team_locks().await,
|
||||||
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
|
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
|
||||||
@@ -1602,6 +1741,18 @@ impl McpServer {
|
|||||||
Self::require_paid_subscription("Browser automation").await?;
|
Self::require_paid_subscription("Browser automation").await?;
|
||||||
self.handle_get_page_info(arguments).await
|
self.handle_get_page_info(arguments).await
|
||||||
}
|
}
|
||||||
|
"get_interactive_elements" => {
|
||||||
|
Self::require_paid_subscription("Browser automation").await?;
|
||||||
|
self.handle_get_interactive_elements(arguments).await
|
||||||
|
}
|
||||||
|
"click_by_index" => {
|
||||||
|
Self::require_paid_subscription("Browser automation").await?;
|
||||||
|
self.handle_click_by_index(arguments).await
|
||||||
|
}
|
||||||
|
"type_by_index" => {
|
||||||
|
Self::require_paid_subscription("Browser automation").await?;
|
||||||
|
self.handle_type_by_index(arguments).await
|
||||||
|
}
|
||||||
_ => Err(McpError {
|
_ => Err(McpError {
|
||||||
code: -32602,
|
code: -32602,
|
||||||
message: format!("Unknown tool: {tool_name}"),
|
message: format!("Unknown tool: {tool_name}"),
|
||||||
@@ -1678,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())
|
||||||
@@ -1687,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);
|
||||||
@@ -1731,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": [{
|
||||||
@@ -1757,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())
|
||||||
@@ -2731,6 +2890,74 @@ impl McpServer {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cookie management handlers
|
||||||
|
async fn handle_import_profile_cookies(
|
||||||
|
&self,
|
||||||
|
arguments: &serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value, McpError> {
|
||||||
|
let profile_id = arguments
|
||||||
|
.get("profile_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| McpError {
|
||||||
|
code: -32602,
|
||||||
|
message: "Missing profile_id".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let content = arguments
|
||||||
|
.get("content")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| McpError {
|
||||||
|
code: -32602,
|
||||||
|
message: "Missing content".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let app_handle = {
|
||||||
|
let inner = self.inner.lock().await;
|
||||||
|
inner
|
||||||
|
.app_handle
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| McpError {
|
||||||
|
code: -32000,
|
||||||
|
message: "MCP server not properly initialized".to_string(),
|
||||||
|
})?
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let result =
|
||||||
|
crate::cookie_manager::CookieManager::import_cookies(&app_handle, profile_id, content)
|
||||||
|
.await
|
||||||
|
.map_err(|e| McpError {
|
||||||
|
code: -32000,
|
||||||
|
message: format!("Failed to import cookies: {e}"),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||||
|
let profile_manager = crate::profile::manager::ProfileManager::instance();
|
||||||
|
if let Ok(profiles) = profile_manager.list_profiles() {
|
||||||
|
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) {
|
||||||
|
if profile.is_sync_enabled() {
|
||||||
|
let pid = profile_id.to_string();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
scheduler.queue_profile_sync(pid).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": format!(
|
||||||
|
"Import complete: {} imported, {} replaced, {} parse error(s)",
|
||||||
|
result.cookies_imported,
|
||||||
|
result.cookies_replaced,
|
||||||
|
result.errors.len()
|
||||||
|
)
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// VPN management handlers
|
// VPN management handlers
|
||||||
async fn handle_import_vpn(
|
async fn handle_import_vpn(
|
||||||
&self,
|
&self,
|
||||||
@@ -4263,6 +4490,11 @@ impl McpServer {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("text");
|
.unwrap_or("text");
|
||||||
let selector = arguments.get("selector").and_then(|v| v.as_str());
|
let selector = arguments.get("selector").and_then(|v| v.as_str());
|
||||||
|
let max_chars = arguments
|
||||||
|
.get("max_chars")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.map(|n| n as usize)
|
||||||
|
.unwrap_or(40_000);
|
||||||
|
|
||||||
let profile = self.get_running_profile(profile_id)?;
|
let profile = self.get_running_profile(profile_id)?;
|
||||||
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
|
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
|
||||||
@@ -4310,10 +4542,28 @@ impl McpServer {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
|
|
||||||
|
// Cap output so a 500 KB DOM dump doesn't blow out the agent's context.
|
||||||
|
// Slice on character boundaries (chars().take().collect()) rather than
|
||||||
|
// byte indices, since the latter would panic on multi-byte boundaries.
|
||||||
|
let total_chars = content.chars().count();
|
||||||
|
let (text, truncated) = if total_chars > max_chars {
|
||||||
|
(content.chars().take(max_chars).collect::<String>(), true)
|
||||||
|
} else {
|
||||||
|
(content.to_string(), false)
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = if truncated {
|
||||||
|
format!(
|
||||||
|
"{text}\n\n[truncated: showing {max_chars} of {total_chars} chars — call with a larger max_chars or use get_interactive_elements for an indexed view]"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
text
|
||||||
|
};
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"content": [{
|
"content": [{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": content
|
"text": payload
|
||||||
}]
|
}]
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -4361,6 +4611,267 @@ impl McpServer {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_get_interactive_elements(
|
||||||
|
&self,
|
||||||
|
arguments: &serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value, McpError> {
|
||||||
|
let profile_id = arguments
|
||||||
|
.get("profile_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| McpError {
|
||||||
|
code: -32602,
|
||||||
|
message: "Missing profile_id".to_string(),
|
||||||
|
})?;
|
||||||
|
let max_chars = arguments
|
||||||
|
.get("max_chars")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.map(|n| n as usize)
|
||||||
|
.unwrap_or(40_000);
|
||||||
|
|
||||||
|
let profile = self.get_running_profile(profile_id)?;
|
||||||
|
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
|
||||||
|
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
|
||||||
|
|
||||||
|
// Walk the DOM for visible, non-disabled interactive elements, label them
|
||||||
|
// with a zero-based index, and cache the live references on
|
||||||
|
// `window.__donut_interactive` so click_by_index / type_by_index can
|
||||||
|
// resolve the index → Element without round-tripping a selector.
|
||||||
|
let js = INTERACTIVE_ELEMENTS_JS.replace("__MAX_CHARS__", &max_chars.to_string());
|
||||||
|
|
||||||
|
let result = self
|
||||||
|
.send_cdp(
|
||||||
|
&ws_url,
|
||||||
|
"Runtime.evaluate",
|
||||||
|
serde_json::json!({
|
||||||
|
"expression": js,
|
||||||
|
"returnByValue": true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(exception) = result.get("exceptionDetails") {
|
||||||
|
let msg = exception
|
||||||
|
.get("exception")
|
||||||
|
.and_then(|e| e.get("description"))
|
||||||
|
.or_else(|| exception.get("text"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Enumeration failed");
|
||||||
|
return Err(McpError {
|
||||||
|
code: -32000,
|
||||||
|
message: msg.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload_str = result
|
||||||
|
.get("result")
|
||||||
|
.and_then(|r| r.get("value"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("{}");
|
||||||
|
|
||||||
|
let payload: serde_json::Value =
|
||||||
|
serde_json::from_str(payload_str).unwrap_or(serde_json::json!({}));
|
||||||
|
let elements = payload
|
||||||
|
.get("elements")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let count = payload.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
let truncated = payload
|
||||||
|
.get("truncated")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let header = if truncated {
|
||||||
|
format!("{count} interactive elements (truncated at {max_chars} chars — re-call with a larger max_chars or scroll the page):")
|
||||||
|
} else {
|
||||||
|
format!("{count} interactive elements:")
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": format!("{header}\n{elements}")
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_click_by_index(
|
||||||
|
&self,
|
||||||
|
arguments: &serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value, McpError> {
|
||||||
|
let profile_id = arguments
|
||||||
|
.get("profile_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| McpError {
|
||||||
|
code: -32602,
|
||||||
|
message: "Missing profile_id".to_string(),
|
||||||
|
})?;
|
||||||
|
let index = arguments
|
||||||
|
.get("index")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.ok_or_else(|| McpError {
|
||||||
|
code: -32602,
|
||||||
|
message: "Missing index".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let profile = self.get_running_profile(profile_id)?;
|
||||||
|
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
|
||||||
|
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
|
||||||
|
|
||||||
|
let js = format!(
|
||||||
|
r#"(() => {{
|
||||||
|
const arr = window.__donut_interactive;
|
||||||
|
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
|
||||||
|
const el = arr[{index}];
|
||||||
|
el.scrollIntoView({{block: 'center'}});
|
||||||
|
el.click();
|
||||||
|
return true;
|
||||||
|
}})()"#
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = self
|
||||||
|
.send_cdp_and_wait_for_load(
|
||||||
|
&ws_url,
|
||||||
|
"Runtime.evaluate",
|
||||||
|
serde_json::json!({
|
||||||
|
"expression": js,
|
||||||
|
"returnByValue": true,
|
||||||
|
}),
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(exception) = result.get("exceptionDetails") {
|
||||||
|
let msg = exception
|
||||||
|
.get("exception")
|
||||||
|
.and_then(|e| e.get("description"))
|
||||||
|
.or_else(|| exception.get("text"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Click failed");
|
||||||
|
return Err(McpError {
|
||||||
|
code: -32000,
|
||||||
|
message: msg.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": format!("Clicked element at index {index}")
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_type_by_index(
|
||||||
|
&self,
|
||||||
|
arguments: &serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value, McpError> {
|
||||||
|
let profile_id = arguments
|
||||||
|
.get("profile_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| McpError {
|
||||||
|
code: -32602,
|
||||||
|
message: "Missing profile_id".to_string(),
|
||||||
|
})?;
|
||||||
|
let index = arguments
|
||||||
|
.get("index")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.ok_or_else(|| McpError {
|
||||||
|
code: -32602,
|
||||||
|
message: "Missing index".to_string(),
|
||||||
|
})?;
|
||||||
|
let text = arguments
|
||||||
|
.get("text")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| McpError {
|
||||||
|
code: -32602,
|
||||||
|
message: "Missing text".to_string(),
|
||||||
|
})?;
|
||||||
|
let clear_first = arguments
|
||||||
|
.get("clear_first")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
let instant = arguments
|
||||||
|
.get("instant")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let wpm = arguments.get("wpm").and_then(|v| v.as_f64());
|
||||||
|
|
||||||
|
let profile = self.get_running_profile(profile_id)?;
|
||||||
|
let cdp_port = self.get_cdp_port_for_profile(&profile).await?;
|
||||||
|
let ws_url = self.get_cdp_ws_url(cdp_port).await?;
|
||||||
|
|
||||||
|
// Mirrors handle_type_text's focus step but resolves the element via the
|
||||||
|
// cached index instead of a CSS selector.
|
||||||
|
let focus_js = if clear_first {
|
||||||
|
format!(
|
||||||
|
r#"(() => {{
|
||||||
|
const arr = window.__donut_interactive;
|
||||||
|
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
|
||||||
|
const el = arr[{index}];
|
||||||
|
el.scrollIntoView({{block: 'center'}});
|
||||||
|
el.focus();
|
||||||
|
el.value = '';
|
||||||
|
el.dispatchEvent(new Event('input', {{bubbles: true}}));
|
||||||
|
return true;
|
||||||
|
}})()"#
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
r#"(() => {{
|
||||||
|
const arr = window.__donut_interactive;
|
||||||
|
if (!arr || !arr[{index}]) throw new Error('No element at index {index}. Call get_interactive_elements first or after navigation.');
|
||||||
|
const el = arr[{index}];
|
||||||
|
el.scrollIntoView({{block: 'center'}});
|
||||||
|
el.focus();
|
||||||
|
return true;
|
||||||
|
}})()"#
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let focus_result = self
|
||||||
|
.send_cdp(
|
||||||
|
&ws_url,
|
||||||
|
"Runtime.evaluate",
|
||||||
|
serde_json::json!({
|
||||||
|
"expression": focus_js,
|
||||||
|
"returnByValue": true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(exception) = focus_result.get("exceptionDetails") {
|
||||||
|
let msg = exception
|
||||||
|
.get("exception")
|
||||||
|
.and_then(|e| e.get("description"))
|
||||||
|
.or_else(|| exception.get("text"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Focus failed");
|
||||||
|
return Err(McpError {
|
||||||
|
code: -32000,
|
||||||
|
message: msg.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if instant {
|
||||||
|
self
|
||||||
|
.send_cdp(
|
||||||
|
&ws_url,
|
||||||
|
"Input.insertText",
|
||||||
|
serde_json::json!({ "text": text }),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
self.send_human_keystrokes(&ws_url, text, wpm).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": format!("Typed text into element at index {index}")
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// --- Synchronizer handlers ---
|
// --- Synchronizer handlers ---
|
||||||
|
|
||||||
async fn handle_start_sync_session(
|
async fn handle_start_sync_session(
|
||||||
@@ -4560,6 +5071,8 @@ mod tests {
|
|||||||
assert!(tool_names.contains(&"delete_extension"));
|
assert!(tool_names.contains(&"delete_extension"));
|
||||||
assert!(tool_names.contains(&"delete_extension_group"));
|
assert!(tool_names.contains(&"delete_extension_group"));
|
||||||
assert!(tool_names.contains(&"assign_extension_group_to_profile"));
|
assert!(tool_names.contains(&"assign_extension_group_to_profile"));
|
||||||
|
// Cookie tools
|
||||||
|
assert!(tool_names.contains(&"import_profile_cookies"));
|
||||||
// Team lock tools
|
// Team lock tools
|
||||||
assert!(tool_names.contains(&"get_team_locks"));
|
assert!(tool_names.contains(&"get_team_locks"));
|
||||||
assert!(tool_names.contains(&"get_team_lock_status"));
|
assert!(tool_names.contains(&"get_team_lock_status"));
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ impl ProfileManager {
|
|||||||
dns_blocklist: None,
|
dns_blocklist: None,
|
||||||
password_protected: false,
|
password_protected: false,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
match self
|
match self
|
||||||
@@ -303,6 +304,7 @@ impl ProfileManager {
|
|||||||
dns_blocklist: None,
|
dns_blocklist: None,
|
||||||
password_protected: false,
|
password_protected: false,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
match self
|
match self
|
||||||
@@ -365,6 +367,7 @@ impl ProfileManager {
|
|||||||
.map(|d| d.as_secs())
|
.map(|d| d.as_secs())
|
||||||
.unwrap_or(0),
|
.unwrap_or(0),
|
||||||
),
|
),
|
||||||
|
updated_at: Some(crate::proxy_manager::now_secs()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save profile info
|
// Save profile info
|
||||||
@@ -377,9 +380,18 @@ impl ProfileManager {
|
|||||||
|
|
||||||
log::info!("Profile '{name}' created successfully with ID: {profile_id}");
|
log::info!("Profile '{name}' created successfully with ID: {profile_id}");
|
||||||
|
|
||||||
// Create user.js with common Firefox preferences and apply proxy settings if provided
|
// `apply_proxy_settings_to_profile` writes a Firefox-style user.js
|
||||||
// Skip for ephemeral profiles since the data dir is created at launch time
|
// with the upstream proxy host. That is wrong for both supported
|
||||||
if !ephemeral {
|
// browser types:
|
||||||
|
// - Camoufox: camoufox_manager rewrites user.js at every launch with
|
||||||
|
// the local donut-proxy host; writing the upstream here leaves a
|
||||||
|
// stale, wrong proxy in user.js until the next launch.
|
||||||
|
// - Wayfern: Chromium gets its proxy via `--proxy-pac-url=` at launch
|
||||||
|
// (see wayfern_manager.rs) and never reads user.js.
|
||||||
|
// So we only call it for any unrecognized browser type that might be
|
||||||
|
// a true Firefox-family target (none currently). Ephemeral profiles
|
||||||
|
// skip regardless because their data dir is created at launch time.
|
||||||
|
if !ephemeral && !matches!(browser, "camoufox" | "wayfern") {
|
||||||
if let Some(proxy_id_ref) = &proxy_id {
|
if let Some(proxy_id_ref) = &proxy_id {
|
||||||
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
|
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
|
||||||
self.apply_proxy_settings_to_profile(&profile_data_dir, &proxy_settings, None)?;
|
self.apply_proxy_settings_to_profile(&profile_data_dir, &proxy_settings, None)?;
|
||||||
@@ -501,6 +513,7 @@ impl ProfileManager {
|
|||||||
|
|
||||||
// Update profile name (no need to move directories since we use UUID)
|
// Update profile name (no need to move directories since we use UUID)
|
||||||
profile.name = new_name.to_string();
|
profile.name = new_name.to_string();
|
||||||
|
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
|
|
||||||
// Save profile with new name
|
// Save profile with new name
|
||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
@@ -710,6 +723,7 @@ impl ProfileManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
profile.group_id = group_id.clone();
|
profile.group_id = group_id.clone();
|
||||||
|
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
|
|
||||||
crate::sync::queue_profile_sync_if_eligible(&profile);
|
crate::sync::queue_profile_sync_if_eligible(&profile);
|
||||||
@@ -764,6 +778,7 @@ impl ProfileManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
profile.tags = deduped;
|
profile.tags = deduped;
|
||||||
|
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
|
|
||||||
// Save profile
|
// Save profile
|
||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
@@ -800,6 +815,7 @@ impl ProfileManager {
|
|||||||
|
|
||||||
// Update note (trim whitespace, set to None if empty)
|
// Update note (trim whitespace, set to None if empty)
|
||||||
profile.note = note.map(|n| n.trim().to_string()).filter(|n| !n.is_empty());
|
profile.note = note.map(|n| n.trim().to_string()).filter(|n| !n.is_empty());
|
||||||
|
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
|
|
||||||
// Save profile
|
// Save profile
|
||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
@@ -829,6 +845,7 @@ impl ProfileManager {
|
|||||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||||
|
|
||||||
profile.launch_hook = Self::normalize_launch_hook(launch_hook)?;
|
profile.launch_hook = Self::normalize_launch_hook(launch_hook)?;
|
||||||
|
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
|
|
||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
|
|
||||||
@@ -860,6 +877,7 @@ impl ProfileManager {
|
|||||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||||
|
|
||||||
profile.proxy_bypass_rules = rules;
|
profile.proxy_bypass_rules = rules;
|
||||||
|
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
|
|
||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
|
|
||||||
@@ -886,6 +904,7 @@ impl ProfileManager {
|
|||||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||||
|
|
||||||
profile.dns_blocklist = dns_blocklist;
|
profile.dns_blocklist = dns_blocklist;
|
||||||
|
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
|
|
||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
|
|
||||||
@@ -1049,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)?;
|
||||||
@@ -1216,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
|
||||||
@@ -1236,18 +1257,34 @@ impl ProfileManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update on-disk browser profile config immediately
|
// Update on-disk browser profile config immediately.
|
||||||
if let Some(proxy_id_ref) = &proxy_id {
|
// Both supported browser types ignore this write (Camoufox rewrites
|
||||||
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
|
// user.js at launch with the local donut-proxy host, Wayfern takes its
|
||||||
let profiles_dir = self.get_profiles_dir();
|
// proxy via `--proxy-pac-url=` and never reads user.js), and for
|
||||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
// Camoufox specifically writing the upstream host here would leave a
|
||||||
self
|
// stale, wrong proxy in user.js until the next launch.
|
||||||
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
|
if !matches!(profile.browser.as_str(), "camoufox" | "wayfern") {
|
||||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
if let Some(proxy_id_ref) = &proxy_id {
|
||||||
format!("Failed to apply proxy settings: {e}").into()
|
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
|
||||||
})?;
|
let profiles_dir = self.get_profiles_dir();
|
||||||
|
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||||
|
self
|
||||||
|
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
|
||||||
|
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||||
|
format!("Failed to apply proxy settings: {e}").into()
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
// Proxy ID provided but proxy not found, disable proxy
|
||||||
|
let profiles_dir = self.get_profiles_dir();
|
||||||
|
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||||
|
self
|
||||||
|
.disable_proxy_settings_in_profile(&profile_path)
|
||||||
|
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||||
|
format!("Failed to disable proxy settings: {e}").into()
|
||||||
|
})?;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Proxy ID provided but proxy not found, disable proxy
|
// No proxy ID provided, disable proxy
|
||||||
let profiles_dir = self.get_profiles_dir();
|
let profiles_dir = self.get_profiles_dir();
|
||||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||||
self
|
self
|
||||||
@@ -1256,15 +1293,6 @@ impl ProfileManager {
|
|||||||
format!("Failed to disable proxy settings: {e}").into()
|
format!("Failed to disable proxy settings: {e}").into()
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// No proxy ID provided, disable proxy
|
|
||||||
let profiles_dir = self.get_profiles_dir();
|
|
||||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
|
||||||
self
|
|
||||||
.disable_proxy_settings_in_profile(&profile_path)
|
|
||||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
|
||||||
format!("Failed to disable proxy settings: {e}").into()
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit profile update event so frontend UIs can refresh immediately (e.g. proxy manager)
|
// Emit profile update event so frontend UIs can refresh immediately (e.g. proxy manager)
|
||||||
@@ -1308,6 +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)
|
||||||
@@ -1352,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);
|
||||||
@@ -1799,10 +1829,17 @@ impl ProfileManager {
|
|||||||
"user_pref(\"startup.homepage_welcome_url\", \"\");".to_string(),
|
"user_pref(\"startup.homepage_welcome_url\", \"\");".to_string(),
|
||||||
"user_pref(\"startup.homepage_welcome_url.additional\", \"\");".to_string(),
|
"user_pref(\"startup.homepage_welcome_url.additional\", \"\");".to_string(),
|
||||||
"user_pref(\"startup.homepage_override_url\", \"\");".to_string(),
|
"user_pref(\"startup.homepage_override_url\", \"\");".to_string(),
|
||||||
// Keep extension updates enabled and allow sideloaded extensions
|
// Keep extension updates enabled and allow sideloaded extensions.
|
||||||
|
// - autoDisableScopes=0: profile-installed extensions are enabled by default.
|
||||||
|
// - startupScanScopes=1: rescan SCOPE_PROFILE on each launch so freshly
|
||||||
|
// dropped .xpi files in <profile>/extensions/ get registered.
|
||||||
|
// - signatures.required=false: accept unsigned/dev .xpi files. Camoufox
|
||||||
|
// is built without MOZ_REQUIRE_SIGNING so this is honored.
|
||||||
"user_pref(\"extensions.update.enabled\", true);".to_string(),
|
"user_pref(\"extensions.update.enabled\", true);".to_string(),
|
||||||
"user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(),
|
"user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(),
|
||||||
"user_pref(\"extensions.autoDisableScopes\", 0);".to_string(),
|
"user_pref(\"extensions.autoDisableScopes\", 0);".to_string(),
|
||||||
|
"user_pref(\"extensions.startupScanScopes\", 1);".to_string(),
|
||||||
|
"user_pref(\"xpinstall.signatures.required\", false);".to_string(),
|
||||||
// Completely disable browser update checking
|
// Completely disable browser update checking
|
||||||
"user_pref(\"app.update.enabled\", false);".to_string(),
|
"user_pref(\"app.update.enabled\", false);".to_string(),
|
||||||
"user_pref(\"app.update.auto\", false);".to_string(),
|
"user_pref(\"app.update.auto\", false);".to_string(),
|
||||||
@@ -2432,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(
|
||||||
@@ -2463,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
|
||||||
@@ -2491,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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1147,14 +1147,17 @@ pub async fn handle_proxy_connection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = handle_connect_from_buffer(
|
if let Err(e) = handle_connect_from_buffer(
|
||||||
stream,
|
stream,
|
||||||
full_request,
|
full_request,
|
||||||
upstream_url,
|
upstream_url,
|
||||||
bypass_matcher,
|
bypass_matcher,
|
||||||
blocklist_matcher,
|
blocklist_matcher,
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
|
log::warn!("CONNECT tunnel ended with error: {e}");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1449,6 +1452,13 @@ async fn handle_connect_from_buffer(
|
|||||||
tracker.record_request(&domain, 0, 0);
|
tracker.record_request(&domain, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"CONNECT {}:{} (upstream={})",
|
||||||
|
target_host,
|
||||||
|
target_port,
|
||||||
|
upstream_url.as_deref().unwrap_or("DIRECT")
|
||||||
|
);
|
||||||
|
|
||||||
// Connect to target (directly or via upstream proxy).
|
// Connect to target (directly or via upstream proxy).
|
||||||
// Returns a BoxedAsyncStream so all upstream types (plain TCP, SOCKS,
|
// Returns a BoxedAsyncStream so all upstream types (plain TCP, SOCKS,
|
||||||
// Shadowsocks) share the same bidirectional-copy tunnel code below.
|
// Shadowsocks) share the same bidirectional-copy tunnel code below.
|
||||||
@@ -1503,12 +1513,46 @@ async fn handle_connect_from_buffer(
|
|||||||
|
|
||||||
let mut buffer = [0u8; 4096];
|
let mut buffer = [0u8; 4096];
|
||||||
let n = proxy_stream.read(&mut buffer).await?;
|
let n = proxy_stream.read(&mut buffer).await?;
|
||||||
let response = String::from_utf8_lossy(&buffer[..n]);
|
let response_full = String::from_utf8_lossy(&buffer[..n]).to_string();
|
||||||
|
let status_line = response_full.lines().next().unwrap_or("").to_string();
|
||||||
|
|
||||||
if !response.starts_with("HTTP/1.1 200") && !response.starts_with("HTTP/1.0 200") {
|
if !response_full.starts_with("HTTP/1.1 200")
|
||||||
return Err(format!("Upstream proxy CONNECT failed: {}", response).into());
|
&& !response_full.starts_with("HTTP/1.0 200")
|
||||||
|
{
|
||||||
|
log::warn!(
|
||||||
|
"Upstream CONNECT to {}:{} via {}:{} rejected: {}",
|
||||||
|
target_host,
|
||||||
|
target_port,
|
||||||
|
proxy_host,
|
||||||
|
proxy_port,
|
||||||
|
status_line
|
||||||
|
);
|
||||||
|
return Err(format!("Upstream proxy CONNECT failed: {response_full}").into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect the buffer-drop race where the upstream returned the
|
||||||
|
// 200 response coalesced with destination bytes — those bytes
|
||||||
|
// would otherwise be silently discarded and the browser would
|
||||||
|
// see a TLS stream missing its first record.
|
||||||
|
let header_end_in_buffer = response_full.find("\r\n\r\n").map(|i| i + 4);
|
||||||
|
if let Some(end) = header_end_in_buffer {
|
||||||
|
if end < n {
|
||||||
|
log::warn!(
|
||||||
|
"Upstream CONNECT response coalesced {} byte(s) of payload — these would be dropped without forwarding",
|
||||||
|
n - end
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Upstream CONNECT to {}:{} via {}:{} accepted ({})",
|
||||||
|
target_host,
|
||||||
|
target_port,
|
||||||
|
proxy_host,
|
||||||
|
proxy_port,
|
||||||
|
status_line
|
||||||
|
);
|
||||||
|
|
||||||
Box::new(proxy_stream)
|
Box::new(proxy_stream)
|
||||||
}
|
}
|
||||||
"socks4" | "socks5" => {
|
"socks4" | "socks5" => {
|
||||||
|
|||||||
@@ -50,12 +50,12 @@ pub struct AppSettings {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mcp_token: Option<String>, // Displayed token for user to copy (not persisted, loaded from encrypted file)
|
pub mcp_token: Option<String>, // Displayed token for user to copy (not persisted, loaded from encrypted file)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt
|
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default
|
||||||
#[serde(default)]
|
|
||||||
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "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
|
||||||
|
|||||||
+337
-270
@@ -10,11 +10,53 @@ use chrono::{DateTime, Utc};
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
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! {
|
||||||
|
static ref SYNC_CANCEL_FLAGS: StdMutex<HashMap<String, Arc<AtomicBool>>> =
|
||||||
|
StdMutex::new(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_sync_cancel(profile_id: &str) -> Arc<AtomicBool> {
|
||||||
|
let mut map = SYNC_CANCEL_FLAGS.lock().unwrap();
|
||||||
|
let flag = Arc::new(AtomicBool::new(false));
|
||||||
|
map.insert(profile_id.to_string(), flag.clone());
|
||||||
|
flag
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_sync_cancel(profile_id: &str) {
|
||||||
|
SYNC_CANCEL_FLAGS.lock().unwrap().remove(profile_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_sync_cancel(profile_id: &str) -> bool {
|
||||||
|
if let Some(flag) = SYNC_CANCEL_FLAGS.lock().unwrap().get(profile_id) {
|
||||||
|
flag.store(true, Ordering::SeqCst);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SyncCancelGuard(String);
|
||||||
|
impl Drop for SyncCancelGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
clear_sync_cancel(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn cancel_profile_sync(profile_id: String) -> Result<bool, String> {
|
||||||
|
Ok(request_sync_cancel(&profile_id))
|
||||||
|
}
|
||||||
|
|
||||||
/// Upload/download concurrency limit
|
/// Upload/download concurrency limit
|
||||||
const SYNC_CONCURRENCY: usize = 32;
|
const SYNC_CONCURRENCY: usize = 32;
|
||||||
|
|
||||||
@@ -321,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,
|
||||||
@@ -391,6 +494,9 @@ impl SyncEngine {
|
|||||||
let profile_dir = profiles_dir.join(profile.id.to_string());
|
let profile_dir = profiles_dir.join(profile.id.to_string());
|
||||||
let profile_id = profile.id.to_string();
|
let profile_id = profile.id.to_string();
|
||||||
|
|
||||||
|
let cancel_flag = register_sync_cancel(&profile_id);
|
||||||
|
let _cancel_guard = SyncCancelGuard(profile_id.clone());
|
||||||
|
|
||||||
// Determine team key prefix for team profiles
|
// Determine team key prefix for team profiles
|
||||||
let key_prefix = Self::get_team_key_prefix(profile).await;
|
let key_prefix = Self::get_team_key_prefix(profile).await;
|
||||||
|
|
||||||
@@ -514,10 +620,16 @@ impl SyncEngine {
|
|||||||
&diff.files_to_upload,
|
&diff.files_to_upload,
|
||||||
encryption_key.as_ref(),
|
encryption_key.as_ref(),
|
||||||
&key_prefix,
|
&key_prefix,
|
||||||
|
&cancel_flag,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cancel_flag.load(Ordering::Relaxed) {
|
||||||
|
log::info!("Sync cancelled for profile {} after uploads", profile_id);
|
||||||
|
return Err(SyncError::Cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
// Perform downloads
|
// Perform downloads
|
||||||
if !diff.files_to_download.is_empty() {
|
if !diff.files_to_download.is_empty() {
|
||||||
self
|
self
|
||||||
@@ -529,10 +641,16 @@ impl SyncEngine {
|
|||||||
&diff.files_to_download,
|
&diff.files_to_download,
|
||||||
encryption_key.as_ref(),
|
encryption_key.as_ref(),
|
||||||
&key_prefix,
|
&key_prefix,
|
||||||
|
&cancel_flag,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cancel_flag.load(Ordering::Relaxed) {
|
||||||
|
log::info!("Sync cancelled for profile {} after downloads", profile_id);
|
||||||
|
return Err(SyncError::Cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
// Delete local files that don't exist remotely (when remote is newer)
|
// Delete local files that don't exist remotely (when remote is newer)
|
||||||
for path in &diff.files_to_delete_local {
|
for path in &diff.files_to_delete_local {
|
||||||
let file_path = profile_dir.join(path);
|
let file_path = profile_dir.join(path);
|
||||||
@@ -823,6 +941,7 @@ impl SyncEngine {
|
|||||||
files: &[super::manifest::ManifestFileEntry],
|
files: &[super::manifest::ManifestFileEntry],
|
||||||
encryption_key: Option<&[u8; 32]>,
|
encryption_key: Option<&[u8; 32]>,
|
||||||
key_prefix: &str,
|
key_prefix: &str,
|
||||||
|
cancel_flag: &Arc<AtomicBool>,
|
||||||
) -> SyncResult<()> {
|
) -> SyncResult<()> {
|
||||||
if files.is_empty() {
|
if files.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -930,6 +1049,13 @@ impl SyncEngine {
|
|||||||
let save_counter = Arc::new(AtomicU64::new(0));
|
let save_counter = Arc::new(AtomicU64::new(0));
|
||||||
|
|
||||||
for file in &files_to_process {
|
for file in &files_to_process {
|
||||||
|
if cancel_flag.load(Ordering::Relaxed) {
|
||||||
|
log::info!(
|
||||||
|
"Upload cancelled for profile {} before scheduling more files",
|
||||||
|
profile_id_owned
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
let sem = semaphore.clone();
|
let sem = semaphore.clone();
|
||||||
let file_path = profile_dir.join(&file.path);
|
let file_path = profile_dir.join(&file.path);
|
||||||
let relative_path = file.path.clone();
|
let relative_path = file.path.clone();
|
||||||
@@ -958,6 +1084,7 @@ impl SyncEngine {
|
|||||||
let resume_state = resume_state.clone();
|
let resume_state = resume_state.clone();
|
||||||
let save_counter = save_counter.clone();
|
let save_counter = save_counter.clone();
|
||||||
let profile_dir_clone = profile_dir.clone();
|
let profile_dir_clone = profile_dir.clone();
|
||||||
|
let cancel_flag_task = cancel_flag.clone();
|
||||||
let content_type = mime_guess::from_path(&file.path)
|
let content_type = mime_guess::from_path(&file.path)
|
||||||
.first()
|
.first()
|
||||||
.map(|m| m.to_string());
|
.map(|m| m.to_string());
|
||||||
@@ -965,6 +1092,10 @@ impl SyncEngine {
|
|||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move {
|
||||||
let _permit = sem.acquire().await.unwrap();
|
let _permit = sem.acquire().await.unwrap();
|
||||||
|
|
||||||
|
if cancel_flag_task.load(Ordering::Relaxed) {
|
||||||
|
return Err((relative_path, "cancelled".to_string(), false));
|
||||||
|
}
|
||||||
|
|
||||||
let data = match fs::read(&file_path) {
|
let data = match fs::read(&file_path) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !critical => {
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !critical => {
|
||||||
@@ -1095,6 +1226,7 @@ impl SyncEngine {
|
|||||||
files: &[super::manifest::ManifestFileEntry],
|
files: &[super::manifest::ManifestFileEntry],
|
||||||
encryption_key: Option<&[u8; 32]>,
|
encryption_key: Option<&[u8; 32]>,
|
||||||
key_prefix: &str,
|
key_prefix: &str,
|
||||||
|
cancel_flag: &Arc<AtomicBool>,
|
||||||
) -> SyncResult<()> {
|
) -> SyncResult<()> {
|
||||||
if files.is_empty() {
|
if files.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -1194,6 +1326,13 @@ impl SyncEngine {
|
|||||||
let save_counter = Arc::new(AtomicU64::new(0));
|
let save_counter = Arc::new(AtomicU64::new(0));
|
||||||
|
|
||||||
for file in &files_to_process {
|
for file in &files_to_process {
|
||||||
|
if cancel_flag.load(Ordering::Relaxed) {
|
||||||
|
log::info!(
|
||||||
|
"Download cancelled for profile {} before scheduling more files",
|
||||||
|
profile_id_owned
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
let sem = semaphore.clone();
|
let sem = semaphore.clone();
|
||||||
let file_path = profile_dir.join(&file.path);
|
let file_path = profile_dir.join(&file.path);
|
||||||
let relative_path = file.path.clone();
|
let relative_path = file.path.clone();
|
||||||
@@ -1222,13 +1361,21 @@ impl SyncEngine {
|
|||||||
let resume_state = resume_state.clone();
|
let resume_state = resume_state.clone();
|
||||||
let save_counter = save_counter.clone();
|
let save_counter = save_counter.clone();
|
||||||
let profile_dir_clone = profile_dir.clone();
|
let profile_dir_clone = profile_dir.clone();
|
||||||
|
let cancel_flag_task = cancel_flag.clone();
|
||||||
|
|
||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move {
|
||||||
let _permit = sem.acquire().await.unwrap();
|
let _permit = sem.acquire().await.unwrap();
|
||||||
|
|
||||||
|
if cancel_flag_task.load(Ordering::Relaxed) {
|
||||||
|
return Err((relative_path, "cancelled".to_string(), false));
|
||||||
|
}
|
||||||
|
|
||||||
// Retry loop for network downloads
|
// Retry loop for network downloads
|
||||||
let mut last_err = String::new();
|
let mut last_err = String::new();
|
||||||
for attempt in 0..MAX_FILE_RETRIES {
|
for attempt in 0..MAX_FILE_RETRIES {
|
||||||
|
if cancel_flag_task.load(Ordering::Relaxed) {
|
||||||
|
return Err((relative_path, "cancelled".to_string(), false));
|
||||||
|
}
|
||||||
match client.download_bytes(&url).await {
|
match client.download_bytes(&url).await {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
let write_data = if let Some(ref key) = enc_key {
|
let write_data = if let Some(ref key) = enc_key {
|
||||||
@@ -1350,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?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1397,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)
|
||||||
@@ -1498,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?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1545,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
|
||||||
@@ -1714,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?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1755,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
|
||||||
@@ -1865,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?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1906,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
|
||||||
@@ -2070,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?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2115,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
|
||||||
@@ -2361,6 +2437,8 @@ impl SyncEngine {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if !manifest.files.is_empty() {
|
if !manifest.files.is_empty() {
|
||||||
|
let cancel_flag = register_sync_cancel(profile_id);
|
||||||
|
let _cancel_guard = SyncCancelGuard(profile_id.to_string());
|
||||||
self
|
self
|
||||||
.download_profile_files(
|
.download_profile_files(
|
||||||
app_handle,
|
app_handle,
|
||||||
@@ -2370,6 +2448,7 @@ impl SyncEngine {
|
|||||||
&manifest.files,
|
&manifest.files,
|
||||||
encryption_key.as_ref(),
|
encryption_key.as_ref(),
|
||||||
key_prefix,
|
key_prefix,
|
||||||
|
&cancel_flag,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
@@ -2506,8 +2585,46 @@ impl SyncEngine {
|
|||||||
profiles_to_check.len()
|
profiles_to_check.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
// For each remote profile, check if it exists locally and download if missing
|
// For each remote profile, check if it exists locally and download if missing.
|
||||||
|
// Skip any profile that has a tombstone — a leftover manifest under a
|
||||||
|
// tombstoned id means delete_prefix raced or partially failed, and
|
||||||
|
// re-downloading it here is what surfaced the "Browsing keeps re-syncing"
|
||||||
|
// bug after a delete.
|
||||||
for (profile_id, key_prefix) in &profiles_to_check {
|
for (profile_id, key_prefix) in &profiles_to_check {
|
||||||
|
let personal_tombstone = format!("tombstones/profiles/{}.json", profile_id);
|
||||||
|
let has_personal_tombstone = matches!(
|
||||||
|
self.client.stat(&personal_tombstone).await,
|
||||||
|
Ok(stat) if stat.exists
|
||||||
|
);
|
||||||
|
let team_tombstone_key = if key_prefix.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(format!(
|
||||||
|
"{}tombstones/profiles/{}.json",
|
||||||
|
key_prefix, profile_id
|
||||||
|
))
|
||||||
|
};
|
||||||
|
let has_team_tombstone = if let Some(ref tk) = team_tombstone_key {
|
||||||
|
matches!(self.client.stat(tk).await, Ok(stat) if stat.exists)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
if has_personal_tombstone || has_team_tombstone {
|
||||||
|
log::info!(
|
||||||
|
"Skipping download of tombstoned profile {} (clearing leftover remote files)",
|
||||||
|
profile_id
|
||||||
|
);
|
||||||
|
let prefix = format!("{}profiles/{}/", key_prefix, profile_id);
|
||||||
|
if let Err(e) = self.client.delete_prefix(&prefix, None).await {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to clear stale remote files for tombstoned profile {}: {}",
|
||||||
|
profile_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
match self
|
match self
|
||||||
.download_profile_if_missing(app_handle, profile_id, key_prefix)
|
.download_profile_if_missing(app_handle, profile_id, key_prefix)
|
||||||
.await
|
.await
|
||||||
@@ -2571,6 +2688,24 @@ impl SyncEngine {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if has_personal_tombstone || has_team_tombstone {
|
if has_personal_tombstone || has_team_tombstone {
|
||||||
|
// Originator guard: re-read the profile right before deleting. If the
|
||||||
|
// local user disabled sync between the snapshot above and this stat
|
||||||
|
// call, they're the one who wrote this tombstone — keep their local
|
||||||
|
// copy. Tombstones must delete remote-originated changes, never the
|
||||||
|
// sender's own data. (Caused mass local deletion in v0.24.x.)
|
||||||
|
let still_sync_enabled = profile_manager
|
||||||
|
.list_profiles()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.id.to_string() == *pid)
|
||||||
|
.is_some_and(|p| p.is_sync_enabled());
|
||||||
|
if !still_sync_enabled {
|
||||||
|
log::info!(
|
||||||
|
"Profile {} has a tombstone but sync is no longer enabled locally — keeping local copy (originating device)",
|
||||||
|
pid
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
log::info!(
|
log::info!(
|
||||||
"Profile {} has remote tombstone, deleting locally (deleted on another device)",
|
"Profile {} has remote tombstone, deleting locally (deleted on another device)",
|
||||||
pid
|
pid
|
||||||
@@ -2948,6 +3083,11 @@ pub async fn set_profile_sync_mode(
|
|||||||
return Err("Cannot modify sync settings for a cross-OS profile".to_string());
|
return Err("Cannot modify sync settings for a cross-OS profile".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let enabling_now = new_mode != SyncMode::Disabled;
|
||||||
|
if enabling_now && profile.process_id.is_some() {
|
||||||
|
return Err(serde_json::json!({ "code": "PROFILE_RUNNING" }).to_string());
|
||||||
|
}
|
||||||
|
|
||||||
if profile.ephemeral {
|
if profile.ephemeral {
|
||||||
return Err("Cannot enable sync for an ephemeral profile".to_string());
|
return Err("Cannot enable sync for an ephemeral profile".to_string());
|
||||||
}
|
}
|
||||||
@@ -3029,6 +3169,22 @@ pub async fn set_profile_sync_mode(
|
|||||||
|
|
||||||
let _ = events::emit("profiles-changed", ());
|
let _ = events::emit("profiles-changed", ());
|
||||||
|
|
||||||
|
// When (re-)enabling sync, clear any stale tombstone from a previous
|
||||||
|
// disable on this device. Otherwise the next reconcile on another
|
||||||
|
// device — or even a race on this one — would see the tombstone and
|
||||||
|
// delete the freshly re-uploaded data.
|
||||||
|
if enabling {
|
||||||
|
if let Ok(engine) = SyncEngine::create_from_settings(&app_handle).await {
|
||||||
|
let key_prefix = SyncEngine::get_team_key_prefix(&profile).await;
|
||||||
|
let personal_tombstone = format!("tombstones/profiles/{}.json", profile_id);
|
||||||
|
let _ = engine.client.delete(&personal_tombstone, None).await;
|
||||||
|
if !key_prefix.is_empty() {
|
||||||
|
let team_tombstone = format!("{}tombstones/profiles/{}.json", key_prefix, profile_id);
|
||||||
|
let _ = engine.client.delete(&team_tombstone, None).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if enabling {
|
if enabling {
|
||||||
let is_running = profile.process_id.is_some();
|
let is_running = profile.process_id.is_some();
|
||||||
|
|
||||||
@@ -3084,28 +3240,25 @@ pub async fn set_profile_sync_mode(
|
|||||||
log::warn!("Scheduler not initialized, sync will not start");
|
log::warn!("Scheduler not initialized, sync will not start");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Delete remote data when disabling sync
|
// Delete remote data when disabling sync. Awaited (not spawned) so the
|
||||||
|
// tombstone write completes before this command returns. A previous
|
||||||
|
// tokio::spawn here allowed the tombstone-write to land *after* a fast
|
||||||
|
// user-triggered re-enable's tombstone-clear, re-introducing the
|
||||||
|
// tombstone and tripping the reconcile-pass deletion of a profile the
|
||||||
|
// user had just re-enabled (e.g. Personal (z.ai) on 2026-05-20).
|
||||||
if old_mode != SyncMode::Disabled {
|
if old_mode != SyncMode::Disabled {
|
||||||
let profile_id_clone = profile_id.clone();
|
match SyncEngine::create_from_settings(&app_handle).await {
|
||||||
let app_handle_clone = app_handle.clone();
|
Ok(engine) => {
|
||||||
tokio::spawn(async move {
|
if let Err(e) = engine.delete_profile(&profile_id).await {
|
||||||
match SyncEngine::create_from_settings(&app_handle_clone).await {
|
log::warn!("Failed to delete profile {} from sync: {}", profile_id, e);
|
||||||
Ok(engine) => {
|
} else {
|
||||||
if let Err(e) = engine.delete_profile(&profile_id_clone).await {
|
log::info!("Profile {} deleted from sync service", profile_id);
|
||||||
log::warn!(
|
|
||||||
"Failed to delete profile {} from sync: {}",
|
|
||||||
profile_id_clone,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
log::info!("Profile {} deleted from sync service", profile_id_clone);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::debug!("Sync not configured, skipping remote deletion: {}", e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
Err(e) => {
|
||||||
|
log::debug!("Sync not configured, skipping remote deletion: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = events::emit(
|
let _ = events::emit(
|
||||||
@@ -3183,6 +3336,28 @@ pub async fn sync_profile(app_handle: tauri::AppHandle, profile_id: String) -> R
|
|||||||
trigger_sync_for_profile(app_handle, profile_id).await
|
trigger_sync_for_profile(app_handle, profile_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ensure the device has either a cloud login or a self-hosted server URL + token.
|
||||||
|
/// Returns a JSON error code string consumable by the frontend translator.
|
||||||
|
async fn ensure_sync_configured(app_handle: &tauri::AppHandle) -> Result<(), String> {
|
||||||
|
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
|
||||||
|
if cloud_logged_in {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let manager = SettingsManager::instance();
|
||||||
|
let settings = manager.load_settings().map_err(|e| {
|
||||||
|
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
if settings.sync_server_url.is_none() {
|
||||||
|
return Err(serde_json::json!({ "code": "SYNC_NOT_CONFIGURED" }).to_string());
|
||||||
|
}
|
||||||
|
let token = manager.get_sync_token(app_handle).await.ok().flatten();
|
||||||
|
if token.is_none() {
|
||||||
|
return Err(serde_json::json!({ "code": "SYNC_NOT_CONFIGURED" }).to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn trigger_sync_for_profile(
|
pub async fn trigger_sync_for_profile(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
profile_id: String,
|
profile_id: String,
|
||||||
@@ -3222,43 +3397,29 @@ pub async fn set_proxy_sync_enabled(
|
|||||||
let proxy = proxies
|
let proxy = proxies
|
||||||
.iter()
|
.iter()
|
||||||
.find(|p| p.id == proxy_id)
|
.find(|p| p.id == proxy_id)
|
||||||
.ok_or_else(|| format!("Proxy with ID '{proxy_id}' not found"))?;
|
.ok_or_else(|| serde_json::json!({ "code": "PROXY_NOT_FOUND" }).to_string())?;
|
||||||
|
|
||||||
// Block modifying sync for cloud-managed proxies
|
// Block modifying sync for cloud-managed proxies
|
||||||
if proxy.is_cloud_managed {
|
if proxy.is_cloud_managed {
|
||||||
return Err("Cannot modify sync for a cloud-managed proxy".to_string());
|
return Err(serde_json::json!({ "code": "CANNOT_MODIFY_CLOUD_MANAGED_PROXY" }).to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// If disabling, check if proxy is used by any synced profile
|
// If disabling, check if proxy is used by any synced profile
|
||||||
if !enabled && is_proxy_used_by_synced_profile(&proxy_id) {
|
if !enabled && is_proxy_used_by_synced_profile(&proxy_id) {
|
||||||
return Err("Sync cannot be disabled while this proxy is used by synced profiles".to_string());
|
return Err(serde_json::json!({ "code": "SYNC_LOCKED_BY_PROFILE" }).to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// If enabling, check that sync settings are configured
|
// If enabling, check that sync settings are configured
|
||||||
if enabled {
|
if enabled {
|
||||||
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
|
ensure_sync_configured(&app_handle).await?;
|
||||||
|
|
||||||
if !cloud_logged_in {
|
|
||||||
let manager = SettingsManager::instance();
|
|
||||||
let settings = manager
|
|
||||||
.load_settings()
|
|
||||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
|
||||||
|
|
||||||
if settings.sync_server_url.is_none() {
|
|
||||||
return Err(
|
|
||||||
"Sync server not configured. Please configure sync settings first.".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
|
|
||||||
if token.is_none() {
|
|
||||||
return Err("Sync token not configured. Please configure sync settings first.".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_last_sync = if enabled { proxy.last_sync } else { None };
|
let new_last_sync = if enabled { proxy.last_sync } else { None };
|
||||||
proxy_manager.set_stored_proxy_sync_state(&proxy_id, enabled, new_last_sync)?;
|
proxy_manager
|
||||||
|
.set_stored_proxy_sync_state(&proxy_id, enabled, new_last_sync)
|
||||||
|
.map_err(|e| {
|
||||||
|
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e } }).to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
let _ = events::emit("stored-proxies-changed", ());
|
let _ = events::emit("stored-proxies-changed", ());
|
||||||
|
|
||||||
@@ -3299,36 +3460,18 @@ pub async fn set_group_sync_enabled(
|
|||||||
groups
|
groups
|
||||||
.iter()
|
.iter()
|
||||||
.find(|g| g.id == group_id)
|
.find(|g| g.id == group_id)
|
||||||
.ok_or_else(|| format!("Group with ID '{group_id}' not found"))?
|
.ok_or_else(|| serde_json::json!({ "code": "GROUP_NOT_FOUND" }).to_string())?
|
||||||
.clone()
|
.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
// If disabling, check if group is used by any synced profile
|
// If disabling, check if group is used by any synced profile
|
||||||
if !enabled && is_group_used_by_synced_profile(&group_id) {
|
if !enabled && is_group_used_by_synced_profile(&group_id) {
|
||||||
return Err("Sync cannot be disabled while this group is used by synced profiles".to_string());
|
return Err(serde_json::json!({ "code": "SYNC_LOCKED_BY_PROFILE" }).to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// If enabling, check that sync settings are configured
|
// If enabling, check that sync settings are configured
|
||||||
if enabled {
|
if enabled {
|
||||||
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
|
ensure_sync_configured(&app_handle).await?;
|
||||||
|
|
||||||
if !cloud_logged_in {
|
|
||||||
let manager = SettingsManager::instance();
|
|
||||||
let settings = manager
|
|
||||||
.load_settings()
|
|
||||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
|
||||||
|
|
||||||
if settings.sync_server_url.is_none() {
|
|
||||||
return Err(
|
|
||||||
"Sync server not configured. Please configure sync settings first.".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
|
|
||||||
if token.is_none() {
|
|
||||||
return Err("Sync token not configured. Please configure sync settings first.".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut updated_group = group.clone();
|
let mut updated_group = group.clone();
|
||||||
@@ -3341,7 +3484,10 @@ pub async fn set_group_sync_enabled(
|
|||||||
{
|
{
|
||||||
let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap();
|
let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap();
|
||||||
if let Err(e) = group_manager.update_group_internal(&updated_group) {
|
if let Err(e) = group_manager.update_group_internal(&updated_group) {
|
||||||
return Err(format!("Failed to update group: {e}"));
|
return Err(
|
||||||
|
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3392,35 +3538,17 @@ pub async fn set_vpn_sync_enabled(
|
|||||||
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
|
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
|
||||||
storage
|
storage
|
||||||
.load_config(&vpn_id)
|
.load_config(&vpn_id)
|
||||||
.map_err(|e| format!("VPN with ID '{vpn_id}' not found: {e}"))?
|
.map_err(|_| serde_json::json!({ "code": "VPN_NOT_FOUND" }).to_string())?
|
||||||
};
|
};
|
||||||
|
|
||||||
// If disabling, check if VPN is used by any synced profile
|
// If disabling, check if VPN is used by any synced profile
|
||||||
if !enabled && is_vpn_used_by_synced_profile(&vpn_id) {
|
if !enabled && is_vpn_used_by_synced_profile(&vpn_id) {
|
||||||
return Err("Sync cannot be disabled while this VPN is used by synced profiles".to_string());
|
return Err(serde_json::json!({ "code": "SYNC_LOCKED_BY_PROFILE" }).to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// If enabling, check that sync settings are configured
|
// If enabling, check that sync settings are configured
|
||||||
if enabled {
|
if enabled {
|
||||||
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
|
ensure_sync_configured(&app_handle).await?;
|
||||||
|
|
||||||
if !cloud_logged_in {
|
|
||||||
let manager = SettingsManager::instance();
|
|
||||||
let settings = manager
|
|
||||||
.load_settings()
|
|
||||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
|
||||||
|
|
||||||
if settings.sync_server_url.is_none() {
|
|
||||||
return Err(
|
|
||||||
"Sync server not configured. Please configure sync settings first.".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
|
|
||||||
if token.is_none() {
|
|
||||||
return Err("Sync token not configured. Please configure sync settings first.".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let last_sync = if enabled { vpn.last_sync } else { None };
|
let last_sync = if enabled { vpn.last_sync } else { None };
|
||||||
@@ -3429,7 +3557,10 @@ pub async fn set_vpn_sync_enabled(
|
|||||||
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
|
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
|
||||||
storage
|
storage
|
||||||
.update_sync_fields(&vpn_id, enabled, last_sync)
|
.update_sync_fields(&vpn_id, enabled, last_sync)
|
||||||
.map_err(|e| format!("Failed to update VPN sync: {e}"))?;
|
.map_err(|e| {
|
||||||
|
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = events::emit("vpn-configs-changed", ());
|
let _ = events::emit("vpn-configs-changed", ());
|
||||||
@@ -3526,48 +3657,10 @@ pub fn get_unsynced_entity_counts() -> Result<UnsyncedEntityCounts, String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Result<(), String> {
|
pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||||
// Enable sync for all eligible profiles. Without this the user would see
|
// Intentionally excludes profiles: enabling profile sync uploads the entire
|
||||||
// groups/proxies/vpns syncing while their profiles stay local-only — the
|
// browser data dir per profile, which is destructive if the user expected
|
||||||
// long-standing source of issue #352. Encrypted mode wins when an E2E
|
// an opt-in. Profile sync stays under explicit per-profile control via
|
||||||
// password is already configured; otherwise we fall back to plain Regular.
|
// set_profile_sync_mode. This command only touches metadata-sized entities.
|
||||||
{
|
|
||||||
let profile_manager = ProfileManager::instance();
|
|
||||||
let profiles = profile_manager
|
|
||||||
.list_profiles()
|
|
||||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
|
||||||
let desired_mode = if encryption::has_e2e_password() {
|
|
||||||
SyncMode::Encrypted
|
|
||||||
} else {
|
|
||||||
SyncMode::Regular
|
|
||||||
};
|
|
||||||
let desired_mode_str = match desired_mode {
|
|
||||||
SyncMode::Encrypted => "Encrypted",
|
|
||||||
SyncMode::Regular => "Regular",
|
|
||||||
SyncMode::Disabled => "Disabled",
|
|
||||||
};
|
|
||||||
for profile in &profiles {
|
|
||||||
// Skip profiles that are already syncing (any non-Disabled mode),
|
|
||||||
// ephemeral profiles (data wipes on quit, sync is meaningless), and
|
|
||||||
// cross-OS profiles (the OS-specific binary isn't installed locally
|
|
||||||
// so a sync round-trip would be one-sided).
|
|
||||||
if profile.sync_mode != SyncMode::Disabled || profile.ephemeral || profile.is_cross_os() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Err(e) = set_profile_sync_mode(
|
|
||||||
app_handle.clone(),
|
|
||||||
profile.id.to_string(),
|
|
||||||
desired_mode_str.to_string(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
log::warn!(
|
|
||||||
"Failed to enable sync for profile {} ({}): {e}",
|
|
||||||
profile.name,
|
|
||||||
profile.id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable sync for all unsynced proxies
|
// Enable sync for all unsynced proxies
|
||||||
{
|
{
|
||||||
@@ -3664,26 +3757,11 @@ pub async fn set_extension_sync_enabled(
|
|||||||
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||||
manager
|
manager
|
||||||
.get_extension(&extension_id)
|
.get_extension(&extension_id)
|
||||||
.map_err(|e| format!("Extension with ID '{extension_id}' not found: {e}"))?
|
.map_err(|_| serde_json::json!({ "code": "EXTENSION_NOT_FOUND" }).to_string())?
|
||||||
};
|
};
|
||||||
|
|
||||||
if enabled {
|
if enabled {
|
||||||
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
|
ensure_sync_configured(&app_handle).await?;
|
||||||
if !cloud_logged_in {
|
|
||||||
let manager = SettingsManager::instance();
|
|
||||||
let settings = manager
|
|
||||||
.load_settings()
|
|
||||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
|
||||||
if settings.sync_server_url.is_none() {
|
|
||||||
return Err(
|
|
||||||
"Sync server not configured. Please configure sync settings first.".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
|
|
||||||
if token.is_none() {
|
|
||||||
return Err("Sync token not configured. Please configure sync settings first.".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut updated_ext = ext;
|
let mut updated_ext = ext;
|
||||||
@@ -3696,7 +3774,10 @@ pub async fn set_extension_sync_enabled(
|
|||||||
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||||
manager
|
manager
|
||||||
.update_extension_internal(&updated_ext)
|
.update_extension_internal(&updated_ext)
|
||||||
.map_err(|e| format!("Failed to update extension sync: {e}"))?;
|
.map_err(|e| {
|
||||||
|
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = events::emit("extensions-changed", ());
|
let _ = events::emit("extensions-changed", ());
|
||||||
@@ -3720,26 +3801,11 @@ pub async fn set_extension_group_sync_enabled(
|
|||||||
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||||
manager
|
manager
|
||||||
.get_group(&extension_group_id)
|
.get_group(&extension_group_id)
|
||||||
.map_err(|e| format!("Extension group with ID '{extension_group_id}' not found: {e}"))?
|
.map_err(|_| serde_json::json!({ "code": "EXTENSION_GROUP_NOT_FOUND" }).to_string())?
|
||||||
};
|
};
|
||||||
|
|
||||||
if enabled {
|
if enabled {
|
||||||
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
|
ensure_sync_configured(&app_handle).await?;
|
||||||
if !cloud_logged_in {
|
|
||||||
let manager = SettingsManager::instance();
|
|
||||||
let settings = manager
|
|
||||||
.load_settings()
|
|
||||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
|
||||||
if settings.sync_server_url.is_none() {
|
|
||||||
return Err(
|
|
||||||
"Sync server not configured. Please configure sync settings first.".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let token = manager.get_sync_token(&app_handle).await.ok().flatten();
|
|
||||||
if token.is_none() {
|
|
||||||
return Err("Sync token not configured. Please configure sync settings first.".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut updated_group = group;
|
let mut updated_group = group;
|
||||||
@@ -3750,9 +3816,10 @@ pub async fn set_extension_group_sync_enabled(
|
|||||||
|
|
||||||
{
|
{
|
||||||
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||||
manager
|
manager.update_group_internal(&updated_group).map_err(|e| {
|
||||||
.update_group_internal(&updated_group)
|
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
|
||||||
.map_err(|e| format!("Failed to update extension group sync: {e}"))?;
|
.to_string()
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = events::emit("extensions-changed", ());
|
let _ = events::emit("extensions-changed", ());
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
|
|||||||
"**/startupCache/**",
|
"**/startupCache/**",
|
||||||
"**/safebrowsing/**",
|
"**/safebrowsing/**",
|
||||||
"**/storage/temporary/**",
|
"**/storage/temporary/**",
|
||||||
|
"**/storage/default/*/cache/**",
|
||||||
|
"**/datareporting/**",
|
||||||
|
"**/saved-telemetry-pings/**",
|
||||||
|
"**/sessionstore-backups/**",
|
||||||
|
"**/sessions/**",
|
||||||
|
"**/serviceworker.txt",
|
||||||
|
"**/AlternateServices.bin",
|
||||||
|
"**/SiteSecurityServiceState.bin",
|
||||||
|
"**/favicons.sqlite",
|
||||||
|
"**/favicons.sqlite-*",
|
||||||
"**/crashes/**",
|
"**/crashes/**",
|
||||||
"**/minidumps/**",
|
"**/minidumps/**",
|
||||||
"*.tmp",
|
"*.tmp",
|
||||||
@@ -52,9 +62,9 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
|
|||||||
"**/BrowserMetrics*",
|
"**/BrowserMetrics*",
|
||||||
"**/.DS_Store",
|
"**/.DS_Store",
|
||||||
".donut-sync/**",
|
".donut-sync/**",
|
||||||
// Local-only marker recording when Wayfern last refreshed this profile's
|
// Orphaned local-only marker from earlier rollover-based fingerprint
|
||||||
// fingerprint. Each device decides its own refresh cadence, so syncing
|
// regeneration. Keep excluding it so any markers left on disk from
|
||||||
// this would cause one device's refresh to silence others.
|
// prior builds never get uploaded.
|
||||||
".last-fp-refresh",
|
".last-fp-refresh",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ pub use encryption::{
|
|||||||
check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password,
|
check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password,
|
||||||
};
|
};
|
||||||
pub use engine::{
|
pub use engine::{
|
||||||
enable_extension_group_sync_if_needed, enable_group_sync_if_needed, enable_proxy_sync_if_needed,
|
cancel_profile_sync, enable_extension_group_sync_if_needed, enable_group_sync_if_needed,
|
||||||
enable_sync_for_all_entities, enable_vpn_sync_if_needed, get_unsynced_entity_counts,
|
enable_proxy_sync_if_needed, enable_sync_for_all_entities, enable_vpn_sync_if_needed,
|
||||||
is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
|
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
|
||||||
is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured,
|
is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured,
|
||||||
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, request_profile_sync,
|
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, request_profile_sync,
|
||||||
rollover_encryption_for_all_entities, set_extension_group_sync_enabled,
|
rollover_encryption_for_all_entities, set_extension_group_sync_enabled,
|
||||||
|
|||||||
@@ -716,16 +716,18 @@ impl SyncScheduler {
|
|||||||
match entity_type.as_str() {
|
match entity_type.as_str() {
|
||||||
"profile" => {
|
"profile" => {
|
||||||
let profile_manager = ProfileManager::instance();
|
let profile_manager = ProfileManager::instance();
|
||||||
let has_profile = {
|
let local_sync_enabled = {
|
||||||
if let Ok(profiles) = profile_manager.list_profiles() {
|
if let Ok(profiles) = profile_manager.list_profiles() {
|
||||||
let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok();
|
let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok();
|
||||||
profile_uuid.is_some_and(|uuid| profiles.iter().any(|p| p.id == uuid))
|
profile_uuid
|
||||||
|
.and_then(|uuid| profiles.into_iter().find(|p| p.id == uuid))
|
||||||
|
.is_some_and(|p| p.is_sync_enabled())
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if has_profile {
|
if local_sync_enabled {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Profile {} was deleted remotely, deleting locally",
|
"Profile {} was deleted remotely, deleting locally",
|
||||||
entity_id
|
entity_id
|
||||||
@@ -733,6 +735,11 @@ impl SyncScheduler {
|
|||||||
if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) {
|
if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) {
|
||||||
log::warn!("Failed to delete tombstoned profile {}: {}", entity_id, e);
|
log::warn!("Failed to delete tombstoned profile {}: {}", entity_id, e);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
log::info!(
|
||||||
|
"Profile {} has a tombstone but sync is no longer enabled locally — keeping local copy",
|
||||||
|
entity_id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"proxy" => {
|
"proxy" => {
|
||||||
|
|||||||
@@ -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)]
|
||||||
@@ -166,6 +180,7 @@ pub enum SyncError {
|
|||||||
SerializationError(String),
|
SerializationError(String),
|
||||||
ConflictError(String),
|
ConflictError(String),
|
||||||
InvalidData(String),
|
InvalidData(String),
|
||||||
|
Cancelled,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for SyncError {
|
impl std::fmt::Display for SyncError {
|
||||||
@@ -178,6 +193,7 @@ impl std::fmt::Display for SyncError {
|
|||||||
SyncError::SerializationError(msg) => write!(f, "Serialization error: {msg}"),
|
SyncError::SerializationError(msg) => write!(f, "Serialization error: {msg}"),
|
||||||
SyncError::ConflictError(msg) => write!(f, "Conflict error: {msg}"),
|
SyncError::ConflictError(msg) => write!(f, "Conflict error: {msg}"),
|
||||||
SyncError::InvalidData(msg) => write!(f, "Invalid data: {msg}"),
|
SyncError::InvalidData(msg) => write!(f, "Invalid data: {msg}"),
|
||||||
|
SyncError::Cancelled => write!(f, "Sync cancelled by user"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ pub struct VpnConfig {
|
|||||||
pub sync_enabled: bool,
|
pub sync_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_sync: Option<u64>,
|
pub last_sync: Option<u64>,
|
||||||
|
/// Unix seconds of the last meaningful user edit. Source of truth for sync
|
||||||
|
/// conflict resolution (last-write-wins); bumped on config edits only.
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parsed WireGuard configuration
|
/// Parsed WireGuard configuration
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ struct StoredVpnConfig {
|
|||||||
sync_enabled: bool,
|
sync_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
last_sync: Option<u64>,
|
last_sync: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
updated_at: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// VPN storage manager with encryption
|
/// VPN storage manager with encryption
|
||||||
@@ -247,6 +249,7 @@ impl VpnStorage {
|
|||||||
last_used: config.last_used,
|
last_used: config.last_used,
|
||||||
sync_enabled: config.sync_enabled,
|
sync_enabled: config.sync_enabled,
|
||||||
last_sync: config.last_sync,
|
last_sync: config.last_sync,
|
||||||
|
updated_at: config.updated_at,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update existing or add new
|
// Update existing or add new
|
||||||
@@ -280,6 +283,7 @@ impl VpnStorage {
|
|||||||
last_used: stored.last_used,
|
last_used: stored.last_used,
|
||||||
sync_enabled: stored.sync_enabled,
|
sync_enabled: stored.sync_enabled,
|
||||||
last_sync: stored.last_sync,
|
last_sync: stored.last_sync,
|
||||||
|
updated_at: stored.updated_at,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +304,7 @@ impl VpnStorage {
|
|||||||
last_used: stored.last_used,
|
last_used: stored.last_used,
|
||||||
sync_enabled: stored.sync_enabled,
|
sync_enabled: stored.sync_enabled,
|
||||||
last_sync: stored.last_sync,
|
last_sync: stored.last_sync,
|
||||||
|
updated_at: stored.updated_at,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
)
|
)
|
||||||
@@ -356,6 +361,7 @@ impl VpnStorage {
|
|||||||
last_used: None,
|
last_used: None,
|
||||||
sync_enabled,
|
sync_enabled,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: Some(crate::proxy_manager::now_secs()),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.save_config(&config)?;
|
self.save_config(&config)?;
|
||||||
@@ -367,6 +373,7 @@ impl VpnStorage {
|
|||||||
pub fn update_config_name(&self, id: &str, new_name: &str) -> Result<VpnConfig, VpnError> {
|
pub fn update_config_name(&self, id: &str, new_name: &str) -> Result<VpnConfig, VpnError> {
|
||||||
let mut config = self.load_config(id)?;
|
let mut config = self.load_config(id)?;
|
||||||
config.name = new_name.to_string();
|
config.name = new_name.to_string();
|
||||||
|
config.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
self.save_config(&config)?;
|
self.save_config(&config)?;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
@@ -420,6 +427,7 @@ impl VpnStorage {
|
|||||||
last_used: None,
|
last_used: None,
|
||||||
sync_enabled,
|
sync_enabled,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: Some(crate::proxy_manager::now_secs()),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.save_config(&config)?;
|
self.save_config(&config)?;
|
||||||
@@ -463,6 +471,7 @@ mod tests {
|
|||||||
last_used: None,
|
last_used: None,
|
||||||
sync_enabled: false,
|
sync_enabled: false,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.save_config(&config).unwrap();
|
storage.save_config(&config).unwrap();
|
||||||
@@ -487,6 +496,7 @@ mod tests {
|
|||||||
last_used: None,
|
last_used: None,
|
||||||
sync_enabled: false,
|
sync_enabled: false,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let config2 = VpnConfig {
|
let config2 = VpnConfig {
|
||||||
@@ -498,6 +508,7 @@ mod tests {
|
|||||||
last_used: Some(3000),
|
last_used: Some(3000),
|
||||||
sync_enabled: false,
|
sync_enabled: false,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.save_config(&config1).unwrap();
|
storage.save_config(&config1).unwrap();
|
||||||
@@ -524,6 +535,7 @@ mod tests {
|
|||||||
last_used: None,
|
last_used: None,
|
||||||
sync_enabled: false,
|
sync_enabled: false,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.save_config(&config).unwrap();
|
storage.save_config(&config).unwrap();
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ pub struct WayfernLaunchResult {
|
|||||||
pub profilePath: Option<String>,
|
pub profilePath: Option<String>,
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
pub cdp_port: Option<u16>,
|
pub cdp_port: Option<u16>,
|
||||||
|
/// The fingerprint Wayfern actually applied, echoed back by
|
||||||
|
/// Wayfern.setFingerprint. It may be UPGRADED from the stored fingerprint
|
||||||
|
/// (e.g. when the stored one targets an older browser version). Internal
|
||||||
|
/// only — the caller persists it to the profile; never sent to the frontend.
|
||||||
|
#[serde(default, skip_serializing)]
|
||||||
|
pub used_fingerprint: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WayfernInstance {
|
struct WayfernInstance {
|
||||||
@@ -132,6 +138,46 @@ impl WayfernManager {
|
|||||||
fingerprint
|
fingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Derive the on-screen window size Chromium should open at, from the stored
|
||||||
|
/// fingerprint. `Wayfern.setFingerprint` only spoofs what the page *reports*
|
||||||
|
/// for `windowOuterWidth`/`screenWidth`/etc.; it does not move or resize the
|
||||||
|
/// real top-level window. Without `--window-size` the OS window keeps
|
||||||
|
/// Chromium's default, so the visible window contradicts the reported
|
||||||
|
/// dimensions — a detectable mismatch. We pass `--window-size` so the actual
|
||||||
|
/// window matches the fingerprint.
|
||||||
|
///
|
||||||
|
/// Keys are the camelCase fields Wayfern uses in its fingerprint
|
||||||
|
/// (`windowOuterWidth`, `screenAvailWidth`, …) — NOT the dotted
|
||||||
|
/// Camoufox-style keys. Preference order, matching how the fingerprint
|
||||||
|
/// describes the window:
|
||||||
|
/// 1. `windowOuterWidth` / `windowOuterHeight` — the real window size.
|
||||||
|
/// 2. `screenAvailWidth` / `screenAvailHeight` — usable screen area.
|
||||||
|
/// 3. `screenWidth` / `screenHeight` — full screen.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the fingerprint carries no usable dimensions, leaving
|
||||||
|
/// Chromium's default untouched. The fingerprint JSON may be the bare object
|
||||||
|
/// or the legacy `{ "fingerprint": {...} }` wrapper.
|
||||||
|
fn window_size_from_fingerprint(fingerprint_json: &str) -> Option<(u32, u32)> {
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(fingerprint_json).ok()?;
|
||||||
|
let fp = parsed.get("fingerprint").unwrap_or(&parsed);
|
||||||
|
let obj = fp.as_object()?;
|
||||||
|
|
||||||
|
// Accept both numeric and stringified numbers (Wayfern emits numbers, but a
|
||||||
|
// CDP echo or older saved fingerprint may stringify them).
|
||||||
|
let read = |key: &str| -> Option<u32> {
|
||||||
|
let v = obj.get(key)?;
|
||||||
|
v.as_u64()
|
||||||
|
.or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
|
||||||
|
.filter(|n| *n > 0)
|
||||||
|
.map(|n| n as u32)
|
||||||
|
};
|
||||||
|
let pair = |w: &str, h: &str| -> Option<(u32, u32)> { Some((read(w)?, read(h)?)) };
|
||||||
|
|
||||||
|
pair("windowOuterWidth", "windowOuterHeight")
|
||||||
|
.or_else(|| pair("screenAvailWidth", "screenAvailHeight"))
|
||||||
|
.or_else(|| pair("screenWidth", "screenHeight"))
|
||||||
|
}
|
||||||
|
|
||||||
async fn wait_for_cdp_ready(
|
async fn wait_for_cdp_ready(
|
||||||
&self,
|
&self,
|
||||||
port: u16,
|
port: u16,
|
||||||
@@ -612,6 +658,18 @@ impl WayfernManager {
|
|||||||
|
|
||||||
if headless {
|
if headless {
|
||||||
args.push("--headless=new".to_string());
|
args.push("--headless=new".to_string());
|
||||||
|
} else if let Some((w, h)) = config
|
||||||
|
.fingerprint
|
||||||
|
.as_deref()
|
||||||
|
.and_then(Self::window_size_from_fingerprint)
|
||||||
|
{
|
||||||
|
// Size the real OS window to match the fingerprint so the visible window
|
||||||
|
// agrees with the reported windowOuterWidth/screen dimensions. Anchor at
|
||||||
|
// 0,0 so the window also fits within the spoofed screen origin. Skipped in
|
||||||
|
// headless mode, where there is no on-screen window.
|
||||||
|
log::info!("Sizing Wayfern window to fingerprint dimensions: {w}x{h}");
|
||||||
|
args.push(format!("--window-size={w},{h}"));
|
||||||
|
args.push("--window-position=0,0".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -703,6 +761,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 +840,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 +928,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 +1070,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 +1113,7 @@ impl WayfernManager {
|
|||||||
profilePath: Some(found_profile_path),
|
profilePath: Some(found_profile_path),
|
||||||
url: None,
|
url: None,
|
||||||
cdp_port,
|
cdp_port,
|
||||||
|
used_fingerprint: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1168,3 +1250,72 @@ impl WayfernManager {
|
|||||||
lazy_static::lazy_static! {
|
lazy_static::lazy_static! {
|
||||||
static ref WAYFERN_MANAGER: WayfernManager = WayfernManager::new();
|
static ref WAYFERN_MANAGER: WayfernManager = WayfernManager::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_size_prefers_outer_window_dimensions() {
|
||||||
|
// Field names + values mirror a real Wayfern fingerprint (camelCase).
|
||||||
|
let fp = r#"{"windowOuterWidth": 1268, "windowOuterHeight": 764,
|
||||||
|
"windowInnerWidth": 1253, "windowInnerHeight": 630,
|
||||||
|
"screenAvailWidth": 1280, "screenAvailHeight": 775,
|
||||||
|
"screenWidth": 1280, "screenHeight": 800}"#;
|
||||||
|
assert_eq!(
|
||||||
|
WayfernManager::window_size_from_fingerprint(fp),
|
||||||
|
Some((1268, 764))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_size_falls_back_to_avail_then_full_screen() {
|
||||||
|
let avail = r#"{"screenAvailWidth": 1280, "screenAvailHeight": 775,
|
||||||
|
"screenWidth": 1280, "screenHeight": 800}"#;
|
||||||
|
assert_eq!(
|
||||||
|
WayfernManager::window_size_from_fingerprint(avail),
|
||||||
|
Some((1280, 775))
|
||||||
|
);
|
||||||
|
|
||||||
|
let full = r#"{"screenWidth": 2560, "screenHeight": 1440}"#;
|
||||||
|
assert_eq!(
|
||||||
|
WayfernManager::window_size_from_fingerprint(full),
|
||||||
|
Some((2560, 1440))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_size_handles_wrapper_and_stringified_numbers() {
|
||||||
|
let wrapped = r#"{"fingerprint": {"windowOuterWidth": "1366", "windowOuterHeight": "768"}}"#;
|
||||||
|
assert_eq!(
|
||||||
|
WayfernManager::window_size_from_fingerprint(wrapped),
|
||||||
|
Some((1366, 768))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_size_none_when_missing_or_invalid() {
|
||||||
|
// No dimensions at all.
|
||||||
|
assert_eq!(
|
||||||
|
WayfernManager::window_size_from_fingerprint(r#"{"userAgent": "x"}"#),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// A width with no matching height is not a usable pair.
|
||||||
|
assert_eq!(
|
||||||
|
WayfernManager::window_size_from_fingerprint(r#"{"windowOuterWidth": 1268}"#),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// Zero is rejected as a degenerate size.
|
||||||
|
assert_eq!(
|
||||||
|
WayfernManager::window_size_from_fingerprint(
|
||||||
|
r#"{"windowOuterWidth": 0, "windowOuterHeight": 0}"#
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// Not valid JSON.
|
||||||
|
assert_eq!(
|
||||||
|
WayfernManager::window_size_from_fingerprint("not json"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Donut",
|
"productName": "Donut",
|
||||||
"version": "0.24.2",
|
"version": "0.25.3",
|
||||||
"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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+299
-42
@@ -3,11 +3,14 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
||||||
|
import { useOnborda } from "onborda";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AccountPage } from "@/components/account-page";
|
import { AccountPage } from "@/components/account-page";
|
||||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||||
import { 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 { 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";
|
||||||
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
|
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
|
||||||
@@ -21,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 {
|
||||||
@@ -34,10 +37,13 @@ import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
|
|||||||
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
|
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
|
||||||
import { type AppPage, RailNav } from "@/components/rail-nav";
|
import { type AppPage, RailNav } from "@/components/rail-nav";
|
||||||
import { SettingsDialog } from "@/components/settings-dialog";
|
import { SettingsDialog } from "@/components/settings-dialog";
|
||||||
|
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";
|
||||||
@@ -53,6 +59,16 @@ 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 {
|
||||||
|
matchesGroupDigit,
|
||||||
|
matchesShortcut,
|
||||||
|
SHORTCUTS,
|
||||||
|
type ShortcutId,
|
||||||
|
} from "@/lib/shortcuts";
|
||||||
import {
|
import {
|
||||||
dismissToast,
|
dismissToast,
|
||||||
showErrorToast,
|
showErrorToast,
|
||||||
@@ -87,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,
|
||||||
@@ -149,6 +254,11 @@ export default function Home() {
|
|||||||
const [proxyManagementInitialTab, setProxyManagementInitialTab] = useState<
|
const [proxyManagementInitialTab, setProxyManagementInitialTab] = useState<
|
||||||
"proxies" | "vpns"
|
"proxies" | "vpns"
|
||||||
>("proxies");
|
>("proxies");
|
||||||
|
const [extensionManagementInitialTab, setExtensionManagementInitialTab] =
|
||||||
|
useState<"extensions" | "groups">("extensions");
|
||||||
|
const [integrationsInitialTab, setIntegrationsInitialTab] = useState<
|
||||||
|
"api" | "mcp"
|
||||||
|
>("api");
|
||||||
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
||||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||||
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
|
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
|
||||||
@@ -201,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);
|
||||||
@@ -221,6 +329,11 @@ export default function Home() {
|
|||||||
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
|
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
|
||||||
const [currentProfileForSync, setCurrentProfileForSync] =
|
const [currentProfileForSync, setCurrentProfileForSync] =
|
||||||
useState<BrowserProfile | null>(null);
|
useState<BrowserProfile | null>(null);
|
||||||
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||||
|
// Owned by page.tsx so the command palette can request opening the profile
|
||||||
|
// info dialog. ProfilesDataTable consumes it through controlled props.
|
||||||
|
const [profileInfoDialog, setProfileInfoDialog] =
|
||||||
|
useState<BrowserProfile | null>(null);
|
||||||
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
||||||
usePermissions();
|
usePermissions();
|
||||||
|
|
||||||
@@ -273,9 +386,134 @@ export default function Home() {
|
|||||||
case "account":
|
case "account":
|
||||||
setAccountDialogOpen(true);
|
setAccountDialogOpen(true);
|
||||||
break;
|
break;
|
||||||
|
case "shortcuts":
|
||||||
|
// Plain page render — nothing else to open.
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const runShortcut = useCallback(
|
||||||
|
(id: ShortcutId) => {
|
||||||
|
switch (id) {
|
||||||
|
case "openPalette":
|
||||||
|
setCommandPaletteOpen(true);
|
||||||
|
break;
|
||||||
|
case "openShortcuts":
|
||||||
|
handleRailNavigate("shortcuts");
|
||||||
|
break;
|
||||||
|
case "importProfile":
|
||||||
|
handleRailNavigate("import");
|
||||||
|
break;
|
||||||
|
case "goProfiles":
|
||||||
|
handleRailNavigate("profiles");
|
||||||
|
break;
|
||||||
|
case "goProxies": {
|
||||||
|
// Mod+N: navigate first time; flip proxies↔vpns on subsequent presses.
|
||||||
|
// handleRailNavigate("proxies"|"vpns") already updates the dialog's
|
||||||
|
// initialTab, so we just pick the right destination.
|
||||||
|
if (currentPage === "proxies") {
|
||||||
|
handleRailNavigate("vpns");
|
||||||
|
} else if (currentPage === "vpns") {
|
||||||
|
handleRailNavigate("proxies");
|
||||||
|
} else {
|
||||||
|
handleRailNavigate(
|
||||||
|
proxyManagementInitialTab === "vpns" ? "vpns" : "proxies",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "goExtensions": {
|
||||||
|
// Mod+E: flip extensions↔groups tab inside the dialog when already there.
|
||||||
|
if (currentPage === "extensions") {
|
||||||
|
setExtensionManagementInitialTab((cur) =>
|
||||||
|
cur === "extensions" ? "groups" : "extensions",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
handleRailNavigate("extensions");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "goGroups":
|
||||||
|
handleRailNavigate("groups");
|
||||||
|
break;
|
||||||
|
case "goIntegrations": {
|
||||||
|
// Mod+I: flip api↔mcp tab when already on integrations.
|
||||||
|
if (currentPage === "integrations") {
|
||||||
|
setIntegrationsInitialTab((cur) => (cur === "api" ? "mcp" : "api"));
|
||||||
|
} else {
|
||||||
|
handleRailNavigate("integrations");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "goAccount":
|
||||||
|
handleRailNavigate("account");
|
||||||
|
break;
|
||||||
|
case "goSettings":
|
||||||
|
handleRailNavigate("settings");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleRailNavigate, currentPage, proxyManagementInitialTab],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ordered list the digit shortcuts and palette consume. "__all__" is index 1
|
||||||
|
// so Mod+1 always lands on the unfiltered view; the user's groups follow.
|
||||||
|
const orderedGroupTargets = useMemo(
|
||||||
|
() => [
|
||||||
|
{ id: "__all__", name: t("rail.profiles") },
|
||||||
|
...groupsData.map((g) => ({ id: g.id, name: g.name })),
|
||||||
|
],
|
||||||
|
[groupsData, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectGroupByDigit = useCallback(
|
||||||
|
(digit: number) => {
|
||||||
|
const target = orderedGroupTargets[digit - 1];
|
||||||
|
if (!target) return;
|
||||||
|
handleRailNavigate("profiles");
|
||||||
|
handleSelectGroup(target.id);
|
||||||
|
},
|
||||||
|
[orderedGroupTargets, handleRailNavigate, handleSelectGroup],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Global keydown — handles Mod+1..9 group jumps first, then falls back to
|
||||||
|
// the static SHORTCUTS table. Skipped while typing in an input, EXCEPT
|
||||||
|
// ⌘K and ⌘/ which are meta-level shortcuts and should always be reachable.
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
const tag = target?.tagName;
|
||||||
|
const isTyping =
|
||||||
|
tag === "INPUT" ||
|
||||||
|
tag === "TEXTAREA" ||
|
||||||
|
tag === "SELECT" ||
|
||||||
|
target?.isContentEditable === true;
|
||||||
|
|
||||||
|
const digit = matchesGroupDigit(e);
|
||||||
|
if (digit !== null) {
|
||||||
|
if (isTyping) return;
|
||||||
|
if (digit - 1 >= orderedGroupTargets.length) return;
|
||||||
|
e.preventDefault();
|
||||||
|
selectGroupByDigit(digit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of SHORTCUTS) {
|
||||||
|
if (!matchesShortcut(s, e)) continue;
|
||||||
|
if (isTyping && s.id !== "openPalette" && s.id !== "openShortcuts") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
runShortcut(s.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [runShortcut, selectGroupByDigit, orderedGroupTargets.length]);
|
||||||
|
|
||||||
// Check for missing binaries and offer to download them
|
// Check for missing binaries and offer to download them
|
||||||
const checkMissingBinaries = useCallback(async () => {
|
const checkMissingBinaries = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -402,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) {
|
||||||
@@ -652,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],
|
||||||
@@ -1031,7 +1254,7 @@ export default function Home() {
|
|||||||
failed_count: payload.failed_count ?? 0,
|
failed_count: payload.failed_count ?? 0,
|
||||||
phase: payload.phase,
|
phase: payload.phase,
|
||||||
},
|
},
|
||||||
{ id: toastId },
|
{ id: toastId, profileId: payload.profile_id },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1046,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();
|
||||||
@@ -1091,7 +1311,6 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
checkStartupPrompt,
|
|
||||||
listenForUrlEvents,
|
listenForUrlEvents,
|
||||||
checkCurrentUrl,
|
checkCurrentUrl,
|
||||||
checkMissingBinaries,
|
checkMissingBinaries,
|
||||||
@@ -1193,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",
|
||||||
@@ -1207,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(() => {
|
||||||
@@ -1228,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(() => {
|
||||||
@@ -1288,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}
|
||||||
@@ -1306,6 +1530,8 @@ export default function Home() {
|
|||||||
{isLoading && groupsData.length === 0 ? null : null}
|
{isLoading && groupsData.length === 0 ? null : null}
|
||||||
<ProfilesDataTable
|
<ProfilesDataTable
|
||||||
profiles={filteredProfiles}
|
profiles={filteredProfiles}
|
||||||
|
infoDialogProfile={profileInfoDialog}
|
||||||
|
onInfoDialogProfileChange={setProfileInfoDialog}
|
||||||
onLaunchProfile={launchProfile}
|
onLaunchProfile={launchProfile}
|
||||||
onKillProfile={handleKillProfile}
|
onKillProfile={handleKillProfile}
|
||||||
onCloneProfile={handleCloneProfile}
|
onCloneProfile={handleCloneProfile}
|
||||||
@@ -1344,6 +1570,10 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{currentPage === "shortcuts" && (
|
||||||
|
<ShortcutsPage groupTargets={orderedGroupTargets} />
|
||||||
|
)}
|
||||||
|
|
||||||
{settingsDialogOpen && (
|
{settingsDialogOpen && (
|
||||||
<SettingsDialog
|
<SettingsDialog
|
||||||
isOpen={settingsDialogOpen}
|
isOpen={settingsDialogOpen}
|
||||||
@@ -1368,6 +1598,7 @@ export default function Home() {
|
|||||||
setCurrentPage("profiles");
|
setCurrentPage("profiles");
|
||||||
}}
|
}}
|
||||||
subPage={currentPage === "integrations"}
|
subPage={currentPage === "integrations"}
|
||||||
|
initialTab={integrationsInitialTab}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1404,6 +1635,7 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
limitedMode={false}
|
limitedMode={false}
|
||||||
subPage={currentPage === "extensions"}
|
subPage={currentPage === "extensions"}
|
||||||
|
initialTab={extensionManagementInitialTab}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1447,6 +1679,29 @@ export default function Home() {
|
|||||||
crossOsUnlocked={crossOsUnlocked}
|
crossOsUnlocked={crossOsUnlocked}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CommandPalette
|
||||||
|
open={commandPaletteOpen}
|
||||||
|
onOpenChange={setCommandPaletteOpen}
|
||||||
|
onAction={runShortcut}
|
||||||
|
groupTargets={orderedGroupTargets}
|
||||||
|
onSelectGroup={(id) => {
|
||||||
|
handleRailNavigate("profiles");
|
||||||
|
handleSelectGroup(id);
|
||||||
|
}}
|
||||||
|
profiles={profiles}
|
||||||
|
runningProfileIds={runningProfiles}
|
||||||
|
onLaunchProfile={(profile) => {
|
||||||
|
void launchProfile(profile);
|
||||||
|
}}
|
||||||
|
onKillProfile={(profile) => {
|
||||||
|
void handleKillProfile(profile);
|
||||||
|
}}
|
||||||
|
onShowProfileInfo={(profile) => {
|
||||||
|
handleRailNavigate("profiles");
|
||||||
|
setProfileInfoDialog(profile);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{pendingUrls.map((pendingUrl) => (
|
{pendingUrls.map((pendingUrl) => (
|
||||||
<ProfileSelectorDialog
|
<ProfileSelectorDialog
|
||||||
key={pendingUrl.id}
|
key={pendingUrl.id}
|
||||||
@@ -1471,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={() => {
|
||||||
@@ -1675,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FaDownload } from "react-icons/fa";
|
||||||
|
import { FiWifi } from "react-icons/fi";
|
||||||
|
import { GoGear } from "react-icons/go";
|
||||||
|
import {
|
||||||
|
LuCircleStop,
|
||||||
|
LuCloud,
|
||||||
|
LuInfo,
|
||||||
|
LuKeyboard,
|
||||||
|
LuPlay,
|
||||||
|
LuPlug,
|
||||||
|
LuPuzzle,
|
||||||
|
LuUser,
|
||||||
|
LuUsers,
|
||||||
|
} from "react-icons/lu";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
CommandShortcut,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
formatGroupShortcut,
|
||||||
|
formatShortcut,
|
||||||
|
SHORTCUTS,
|
||||||
|
type ShortcutDef,
|
||||||
|
type ShortcutId,
|
||||||
|
} from "@/lib/shortcuts";
|
||||||
|
import type { BrowserProfile } from "@/types";
|
||||||
|
|
||||||
|
interface GroupTarget {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandPaletteProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onAction: (id: ShortcutId) => void;
|
||||||
|
/** Ordered list of groups for Mod+1..9. Index 0 is the catch-all entry. */
|
||||||
|
groupTargets: GroupTarget[];
|
||||||
|
onSelectGroup: (id: string) => void;
|
||||||
|
/** All profiles for launch/stop/info entries. */
|
||||||
|
profiles: BrowserProfile[];
|
||||||
|
runningProfileIds: Set<string>;
|
||||||
|
onLaunchProfile: (profile: BrowserProfile) => void;
|
||||||
|
onKillProfile: (profile: BrowserProfile) => void;
|
||||||
|
onShowProfileInfo: (profile: BrowserProfile) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
|
||||||
|
openPalette: LuKeyboard,
|
||||||
|
openShortcuts: LuKeyboard,
|
||||||
|
importProfile: FaDownload,
|
||||||
|
goProfiles: LuUser,
|
||||||
|
goProxies: FiWifi,
|
||||||
|
goExtensions: LuPuzzle,
|
||||||
|
goGroups: LuUsers,
|
||||||
|
goIntegrations: LuPlug,
|
||||||
|
goAccount: LuCloud,
|
||||||
|
goSettings: GoGear,
|
||||||
|
};
|
||||||
|
|
||||||
|
function Tokens({ tokens }: { tokens: string[] }) {
|
||||||
|
return (
|
||||||
|
<CommandShortcut className="flex items-center gap-0.5">
|
||||||
|
{tokens.map((tok, i) => (
|
||||||
|
<kbd
|
||||||
|
key={i}
|
||||||
|
className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded border border-border bg-muted text-[10px] font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
{tok}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</CommandShortcut>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShortcutTokens({ shortcut }: { shortcut: ShortcutDef }) {
|
||||||
|
return <Tokens tokens={formatShortcut(shortcut)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token-AND fuzzy filter. Every whitespace-separated token in the query has
|
||||||
|
* to appear as a substring somewhere in the item's value or its keywords; the
|
||||||
|
* score is reduced when tokens appear later in the haystack so a closer match
|
||||||
|
* sorts higher. "ctest info" matches "Info — ctest" — the default cmdk filter
|
||||||
|
* requires tokens in document order so it would otherwise return zero.
|
||||||
|
*/
|
||||||
|
function fuzzyFilter(
|
||||||
|
value: string,
|
||||||
|
search: string,
|
||||||
|
keywords?: string[],
|
||||||
|
): number {
|
||||||
|
if (!search.trim()) return 1;
|
||||||
|
const haystack = [value, ...(keywords ?? [])].join(" ").toLowerCase();
|
||||||
|
const tokens = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
let score = 0;
|
||||||
|
for (const tok of tokens) {
|
||||||
|
const idx = haystack.indexOf(tok);
|
||||||
|
if (idx === -1) return 0;
|
||||||
|
score += 1 / (1 + idx);
|
||||||
|
}
|
||||||
|
return score / tokens.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPalette({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onAction,
|
||||||
|
groupTargets,
|
||||||
|
onSelectGroup,
|
||||||
|
profiles,
|
||||||
|
runningProfileIds,
|
||||||
|
onLaunchProfile,
|
||||||
|
onKillProfile,
|
||||||
|
onShowProfileInfo,
|
||||||
|
}: CommandPaletteProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// `cmdk` calls onSelect BEFORE the dialog closes. Close first, then dispatch
|
||||||
|
// on the next tick so an action that opens another dialog doesn't race
|
||||||
|
// this one's close animation.
|
||||||
|
const dispatch = (fn: () => void) => {
|
||||||
|
onOpenChange(false);
|
||||||
|
setTimeout(fn, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const byGroup = (group: ShortcutDef["group"]) =>
|
||||||
|
SHORTCUTS.filter((s) => s.group === group);
|
||||||
|
|
||||||
|
// Limit to 9 — only the first 9 group targets have a Mod+digit binding.
|
||||||
|
// We still display more in the palette (without a shortcut hint) so the
|
||||||
|
// user can search/jump to any of them.
|
||||||
|
const renderGroup = (target: GroupTarget, index: number) => (
|
||||||
|
<CommandItem
|
||||||
|
key={target.id}
|
||||||
|
onSelect={() => {
|
||||||
|
dispatch(() => {
|
||||||
|
onSelectGroup(target.id);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuUsers />
|
||||||
|
<span>{target.name}</span>
|
||||||
|
{index < 9 ? <Tokens tokens={formatGroupShortcut(index + 1)} /> : null}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandDialog open={open} onOpenChange={onOpenChange} filter={fuzzyFilter}>
|
||||||
|
<CommandInput placeholder={t("commandPalette.placeholder")} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>{t("commandPalette.empty")}</CommandEmpty>
|
||||||
|
|
||||||
|
<CommandGroup heading={t("commandPalette.groups.navigation")}>
|
||||||
|
{byGroup("navigation").map((s) => {
|
||||||
|
const Icon = ICONS[s.id];
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={s.id}
|
||||||
|
onSelect={() => {
|
||||||
|
dispatch(() => {
|
||||||
|
onAction(s.id);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
<span>{t(s.labelKey)}</span>
|
||||||
|
<ShortcutTokens shortcut={s} />
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
|
||||||
|
{groupTargets.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading={t("commandPalette.groups.profileGroups")}>
|
||||||
|
{groupTargets.map((target, i) => renderGroup(target, i))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{profiles.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading={t("commandPalette.groups.profiles")}>
|
||||||
|
{profiles.map((p) => {
|
||||||
|
const running = runningProfileIds.has(p.id);
|
||||||
|
return running ? (
|
||||||
|
<CommandItem
|
||||||
|
key={`run-${p.id}`}
|
||||||
|
onSelect={() => {
|
||||||
|
dispatch(() => {
|
||||||
|
onKillProfile(p);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuCircleStop />
|
||||||
|
<span>
|
||||||
|
{t("commandPalette.actions.stopProfile", {
|
||||||
|
name: p.name,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
) : (
|
||||||
|
<CommandItem
|
||||||
|
key={`run-${p.id}`}
|
||||||
|
onSelect={() => {
|
||||||
|
dispatch(() => {
|
||||||
|
onLaunchProfile(p);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuPlay />
|
||||||
|
<span>
|
||||||
|
{t("commandPalette.actions.launchProfile", {
|
||||||
|
name: p.name,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{profiles.map((p) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`info-${p.id}`}
|
||||||
|
onSelect={() => {
|
||||||
|
dispatch(() => {
|
||||||
|
onShowProfileInfo(p);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuInfo />
|
||||||
|
<span>
|
||||||
|
{t("commandPalette.actions.profileInfo", { name: p.name })}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<CommandSeparator />
|
||||||
|
|
||||||
|
<CommandGroup heading={t("commandPalette.groups.actions")}>
|
||||||
|
{byGroup("actions").map((s) => {
|
||||||
|
const Icon = ICONS[s.id];
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={s.id}
|
||||||
|
onSelect={() => {
|
||||||
|
dispatch(() => {
|
||||||
|
onAction(s.id);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
<span>{t(s.labelKey)}</span>
|
||||||
|
<ShortcutTokens shortcut={s} />
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function DeleteConfirmationDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent>
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||||
import type { Extension, ExtensionGroup } from "@/types";
|
import type { Extension, ExtensionGroup } from "@/types";
|
||||||
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
|
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
|
||||||
@@ -130,6 +131,8 @@ interface ExtensionManagementDialogProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
limitedMode: boolean;
|
limitedMode: boolean;
|
||||||
subPage?: boolean;
|
subPage?: boolean;
|
||||||
|
/** Which tab is displayed when the dialog mounts; defaults to "extensions". */
|
||||||
|
initialTab?: "extensions" | "groups";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExtensionManagementDialog({
|
export function ExtensionManagementDialog({
|
||||||
@@ -137,6 +140,7 @@ export function ExtensionManagementDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
limitedMode,
|
limitedMode,
|
||||||
subPage,
|
subPage,
|
||||||
|
initialTab = "extensions",
|
||||||
}: ExtensionManagementDialogProps) {
|
}: ExtensionManagementDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||||
@@ -208,9 +212,10 @@ export function ExtensionManagementDialog({
|
|||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({});
|
>({});
|
||||||
|
|
||||||
// Tab
|
// Tab — keyed off `initialTab` so remounting the dialog with a new initial
|
||||||
|
// tab (e.g. via the Mod+E shortcut toggle) jumps to that tab.
|
||||||
const [activeTab, setActiveTab] = useState<"extensions" | "groups">(
|
const [activeTab, setActiveTab] = useState<"extensions" | "groups">(
|
||||||
"extensions",
|
initialTab,
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
@@ -304,7 +309,11 @@ export function ExtensionManagementDialog({
|
|||||||
);
|
);
|
||||||
void loadData();
|
void loadData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
showErrorToast(
|
||||||
|
parseBackendError(err)
|
||||||
|
? translateBackendError(t, err)
|
||||||
|
: t("proxies.management.updateSyncFailed"),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: false }));
|
setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: false }));
|
||||||
}
|
}
|
||||||
@@ -327,7 +336,11 @@ export function ExtensionManagementDialog({
|
|||||||
);
|
);
|
||||||
void loadData();
|
void loadData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
showErrorToast(
|
||||||
|
parseBackendError(err)
|
||||||
|
? translateBackendError(t, err)
|
||||||
|
: t("proxies.management.updateSyncFailed"),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: false }));
|
setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: false }));
|
||||||
}
|
}
|
||||||
@@ -585,9 +598,15 @@ export function ExtensionManagementDialog({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const failed = results.filter((r) => r.status === "rejected").length;
|
const firstRejection = results.find((r) => r.status === "rejected") as
|
||||||
if (failed > 0) {
|
| PromiseRejectedResult
|
||||||
showErrorToast(t("proxies.management.updateSyncFailed"));
|
| undefined;
|
||||||
|
if (firstRejection) {
|
||||||
|
showErrorToast(
|
||||||
|
parseBackendError(firstRejection.reason)
|
||||||
|
? translateBackendError(t, firstRejection.reason)
|
||||||
|
: t("proxies.management.updateSyncFailed"),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
showSuccessToast(
|
showSuccessToast(
|
||||||
targetEnabled
|
targetEnabled
|
||||||
@@ -610,9 +629,15 @@ export function ExtensionManagementDialog({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const failed = results.filter((r) => r.status === "rejected").length;
|
const firstRejection = results.find((r) => r.status === "rejected") as
|
||||||
if (failed > 0) {
|
| PromiseRejectedResult
|
||||||
showErrorToast(t("proxies.management.updateSyncFailed"));
|
| undefined;
|
||||||
|
if (firstRejection) {
|
||||||
|
showErrorToast(
|
||||||
|
parseBackendError(firstRejection.reason)
|
||||||
|
? translateBackendError(t, firstRejection.reason)
|
||||||
|
: t("proxies.management.updateSyncFailed"),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
showSuccessToast(
|
showSuccessToast(
|
||||||
targetEnabled
|
targetEnabled
|
||||||
@@ -1104,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 />
|
||||||
@@ -1120,6 +1145,7 @@ export function ExtensionManagementDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<AnimatedTabs
|
<AnimatedTabs
|
||||||
|
key={initialTab}
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(v) => setActiveTab(v as "extensions" | "groups")}
|
onValueChange={(v) => setActiveTab(v as "extensions" | "groups")}
|
||||||
className="flex-1 min-h-0 flex flex-col"
|
className="flex-1 min-h-0 flex flex-col"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||||
import type { GroupWithCount, ProfileGroup } from "@/types";
|
import type { GroupWithCount, ProfileGroup } from "@/types";
|
||||||
import { RippleButton } from "./ui/ripple";
|
import { RippleButton } from "./ui/ripple";
|
||||||
@@ -262,8 +263,8 @@ export function GroupManagementDialog({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to toggle sync:", error);
|
console.error("Failed to toggle sync:", error);
|
||||||
showErrorToast(
|
showErrorToast(
|
||||||
error instanceof Error
|
parseBackendError(error)
|
||||||
? error.message
|
? translateBackendError(t, error)
|
||||||
: t("proxies.management.updateSyncFailed"),
|
: t("proxies.management.updateSyncFailed"),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -529,9 +530,15 @@ export function GroupManagementDialog({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const failed = results.filter((r) => r.status === "rejected").length;
|
const firstRejection = results.find((r) => r.status === "rejected") as
|
||||||
if (failed > 0) {
|
| PromiseRejectedResult
|
||||||
showErrorToast(t("proxies.management.updateSyncFailed"));
|
| undefined;
|
||||||
|
if (firstRejection) {
|
||||||
|
showErrorToast(
|
||||||
|
parseBackendError(firstRejection.reason)
|
||||||
|
? translateBackendError(t, firstRejection.reason)
|
||||||
|
: t("proxies.management.updateSyncFailed"),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
showSuccessToast(
|
showSuccessToast(
|
||||||
targetEnabled
|
targetEnabled
|
||||||
|
|||||||
@@ -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,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ interface IntegrationsDialogProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
subPage?: boolean;
|
subPage?: boolean;
|
||||||
|
/** Which tab is displayed when the dialog mounts; defaults to "api". */
|
||||||
|
initialTab?: "api" | "mcp";
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentIcon({ category }: { category: AgentCategory }) {
|
function AgentIcon({ category }: { category: AgentCategory }) {
|
||||||
@@ -98,6 +100,7 @@ export function IntegrationsDialog({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
subPage,
|
subPage,
|
||||||
|
initialTab = "api",
|
||||||
}: IntegrationsDialogProps) {
|
}: IntegrationsDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [settings, setSettings] = useState<AppSettings>({
|
const [settings, setSettings] = useState<AppSettings>({
|
||||||
@@ -117,6 +120,7 @@ export function IntegrationsDialog({
|
|||||||
const [isMcpStarting, setIsMcpStarting] = useState(false);
|
const [isMcpStarting, setIsMcpStarting] = useState(false);
|
||||||
const [agents, setAgents] = useState<McpAgentInfo[]>([]);
|
const [agents, setAgents] = useState<McpAgentInfo[]>([]);
|
||||||
const [busyAgentIds, setBusyAgentIds] = useState<Set<string>>(new Set());
|
const [busyAgentIds, setBusyAgentIds] = useState<Set<string>>(new Set());
|
||||||
|
const [apiPortDraft, setApiPortDraft] = useState<string>("10108");
|
||||||
|
|
||||||
const { termsAccepted } = useWayfernTerms();
|
const { termsAccepted } = useWayfernTerms();
|
||||||
|
|
||||||
@@ -124,6 +128,7 @@ export function IntegrationsDialog({
|
|||||||
try {
|
try {
|
||||||
const loaded = await invoke<AppSettings>("get_app_settings");
|
const loaded = await invoke<AppSettings>("get_app_settings");
|
||||||
setSettings(loaded);
|
setSettings(loaded);
|
||||||
|
setApiPortDraft(String(loaded.api_port ?? ""));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load settings:", e);
|
console.error("Failed to load settings:", e);
|
||||||
}
|
}
|
||||||
@@ -310,7 +315,7 @@ export function IntegrationsDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-y-auto flex-1 min-h-0">
|
<div className="overflow-y-auto flex-1 min-h-0">
|
||||||
<AnimatedTabs defaultValue="api">
|
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
|
||||||
<AnimatedTabsList>
|
<AnimatedTabsList>
|
||||||
<AnimatedTabsTrigger value="api">
|
<AnimatedTabsTrigger value="api">
|
||||||
{t("integrations.tabApi")}
|
{t("integrations.tabApi")}
|
||||||
@@ -367,13 +372,24 @@ export function IntegrationsDialog({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={settings.api_port}
|
value={apiPortDraft}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
setApiPortDraft(e.target.value);
|
||||||
const val = Number.parseInt(e.target.value, 10);
|
const val = Number.parseInt(e.target.value, 10);
|
||||||
if (!Number.isNaN(val)) {
|
if (
|
||||||
|
!Number.isNaN(val) &&
|
||||||
|
val >= 1 &&
|
||||||
|
val <= 65535
|
||||||
|
) {
|
||||||
setSettings({ ...settings, api_port: val });
|
setSettings({ ...settings, api_port: val });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
const val = Number.parseInt(apiPortDraft, 10);
|
||||||
|
if (Number.isNaN(val) || val < 1 || val > 65535) {
|
||||||
|
setApiPortDraft(String(settings.api_port));
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="w-24 font-mono"
|
className="w-24 font-mono"
|
||||||
min={1}
|
min={1}
|
||||||
max={65535}
|
max={65535}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ type Props = ButtonProps & {
|
|||||||
export const LoadingButton = ({ isLoading, className, ...props }: Props) => {
|
export const LoadingButton = ({ isLoading, className, ...props }: Props) => {
|
||||||
return (
|
return (
|
||||||
<UIButton
|
<UIButton
|
||||||
className={cn("grid place-items-center", className)}
|
className={cn("inline-flex items-center justify-center", className)}
|
||||||
{...props}
|
{...props}
|
||||||
disabled={props.disabled || isLoading}
|
disabled={props.disabled || isLoading}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user