From 34450ad06b693d054f7994927210bc0b11a047b8 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Tue, 5 May 2026 22:33:43 +0400 Subject: [PATCH] refactor: cleanup --- .env.example | 8 + .envrc | 4 + .github/workflows/issue-validation.yml | 264 ++++++++-- package.json | 4 +- scripts/run-with-env.mjs | 58 +++ src-tauri/src/app_auto_updater.rs | 41 +- src-tauri/src/cloud_auth.rs | 20 +- src-tauri/src/extraction.rs | 93 +++- src-tauri/src/lib.rs | 32 +- src-tauri/src/proxy_manager.rs | 62 ++- src-tauri/src/proxy_server.rs | 124 +++-- src-tauri/src/wayfern_manager.rs | 15 +- src/app/page.tsx | 62 ++- src/components/create-profile-dialog.tsx | 2 +- src/components/device-code-verify-dialog.tsx | 119 +++++ src/components/home-header.tsx | 2 +- src/components/permission-dialog.tsx | 122 ++++- src/components/profile-data-table.tsx | 7 + src/components/settings-dialog.tsx | 9 +- src/components/sync-config-dialog.tsx | 76 +-- src/hooks/use-permissions.ts | 2 +- src/i18n/locales/en.json | 6 +- src/i18n/locales/es.json | 6 +- src/i18n/locales/fr.json | 6 +- src/i18n/locales/ja.json | 6 +- src/i18n/locales/pt.json | 6 +- src/i18n/locales/ru.json | 6 +- src/i18n/locales/zh.json | 6 +- src/lib/themes.ts | 513 +++++++++++++++---- 29 files changed, 1312 insertions(+), 369 deletions(-) create mode 100755 scripts/run-with-env.mjs create mode 100644 src/components/device-code-verify-dialog.tsx diff --git a/.env.example b/.env.example index 80825ec..d3f526f 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,12 @@ +# macOS code signing + notarization for `pnpm tauri build`. +# Loaded into the build environment via scripts/run-with-env.mjs (and direnv via .envrc). +# APPLE_SIGNING_IDENTITY: the exact name of your Developer ID Application +# certificate as it appears in `security find-identity -v -p codesigning`. +# Example: "Developer ID Application: Your Name (TEAMID)" +# APPLE_ID + APPLE_PASSWORD + APPLE_TEAM_ID: credentials for notarytool. +# APPLE_PASSWORD must be an app-specific password from appleid.apple.com, +# not your real Apple ID password. APPLE_TEAM_ID= APPLE_ID= APPLE_PASSWORD= diff --git a/.envrc b/.envrc index 3550a30..bb0f059 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,5 @@ use flake +# Load .env on top of the flake's environment so APPLE_SIGNING_IDENTITY, +# APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID etc. are available to `tauri build` +# and any other tools spawned from this directory. +dotenv_if_exists .env diff --git a/.github/workflows/issue-validation.yml b/.github/workflows/issue-validation.yml index 4790e1a..7926c4b 100644 --- a/.github/workflows/issue-validation.yml +++ b/.github/workflows/issue-validation.yml @@ -16,6 +16,11 @@ permissions: pull-requests: write id-token: write +env: + # Single source of truth for the model used by both triage and composer. + TRIAGE_MODEL: anthropic/claude-opus-4.7 + COMPOSER_MODEL: anthropic/claude-opus-4.7 + jobs: analyze-issue: if: github.repository == 'zhom/donutbrowser' && github.event_name == 'issues' @@ -40,41 +45,150 @@ jobs: echo "is_first_time=false" >> $GITHUB_OUTPUT fi - - name: Build repo context and find related files + - name: Parse issue template fields + env: + ISSUE_BODY: ${{ github.event.issue.body }} + run: | + node <<'EOF' + const fs = require('node:fs'); + const body = process.env.ISSUE_BODY || ''; + // GitHub issue templates render fields as `### Heading\nValue` blocks. + // Split on `###` at line start to recover them. + const fields = {}; + const sections = body.split(/^###\s+/m); + for (const section of sections.slice(1)) { + const nl = section.indexOf('\n'); + if (nl < 0) continue; + const heading = section.slice(0, nl).trim(); + const value = section.slice(nl + 1).trim(); + fields[heading] = value === '_No response_' ? '' : value; + } + fs.writeFileSync('/tmp/issue-fields.json', JSON.stringify(fields, null, 2)); + // Convenience extractions for the prompt — empty string if missing. + const get = (k) => fields[k] || ''; + fs.writeFileSync('/tmp/issue-os.txt', get('Operating System')); + fs.writeFileSync('/tmp/issue-version.txt', get('Donut Browser version')); + fs.writeFileSync('/tmp/issue-browser.txt', get('Which browser is affected?')); + fs.writeFileSync('/tmp/issue-repro.txt', get('Steps to reproduce')); + fs.writeFileSync('/tmp/issue-logs.txt', get('Error logs or screenshots')); + fs.writeFileSync('/tmp/issue-what.txt', get('What happened?') || get('What do you want?')); + EOF + echo "Parsed fields:" + cat /tmp/issue-fields.json + + - name: Build repo context env: ISSUE_TITLE: ${{ github.event.issue.title }} ISSUE_BODY: ${{ github.event.issue.body }} run: | - # Read project guidelines (contains repo structure) cp CLAUDE.md /tmp/repo-context.txt - printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt - # List all source files for the AI to pick from + # List all source files for the AI to choose from find . -type f \( -name "*.rs" -o -name "*.ts" -o -name "*.tsx" \) \ ! -path "*/node_modules/*" ! -path "*/target/*" ! -path "*/.next/*" ! -path "*/dist/*" \ ! -path "*/.git/*" ! -path "*/gen/*" ! -path "*/data/*" \ | sed 's|^\./||' | sort > /tmp/all-source-files.txt - - name: Select relevant files with AI + - name: Write shared knowledge files (scope + pricing) + run: | + cat > /tmp/scope-and-pricing.md <<'EOF' + # PROJECT SCOPE + + - **Donut Browser** — this repo. A Tauri desktop launcher (Rust + Next.js) that + downloads, manages, and launches anti-detect browser profiles. In-scope for bug + reports about profile management, downloads, sync, proxy, VPN, the launcher UI, + its API, MCP server, and the bundled `donut-sync` self-hosted server. + - **Wayfern** — a Chromium fork maintained by zhom (the same maintainer). Wayfern + 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 + contribute to Camoufox and CANNOT fix bugs in it. + - Bugs about Camoufox's *internal* behavior (page rendering, JS engine, + dropdowns, form widgets, fingerprinting *as Camoufox implements it*, + checkbox/radio quirks) are UPSTREAM ONLY. Redirect to + https://github.com/daijro/camoufox/issues. + - Bugs about how Donut *launches, configures, or downloads* Camoufox are + in-scope here. + - **Forks of Wayfern or Camoufox** (e.g. CloverLabsAI, VulpineOS) are NOT + supported. Feature requests asking for them are out of scope. + + # PAID vs FREE FEATURES + + Source: donutbrowser.com pricing tiers (verbatim from translations). + + ## Free (no account required) + - Unlimited local profiles + - Chromium (Wayfern) and Firefox (Camoufox) browser engines + - Proxy support (HTTP/SOCKS5) + - VPN support (WireGuard) + - Profile Management API & MCP (list / create / launch / kill / config) + - Cookie & Extension Management + - Set as default browser + - **Profile sync IS FREE if the user self-hosts the `donut-sync` server** + + ## Pro ($16/mo) — adds: + - Browser Manipulation API & MCP (`type_text`, `click_element`, + `evaluate_javascript`, `screenshot`, `navigate`, etc.) + - Cross-OS fingerprinting (e.g. macOS user appearing as Windows) + - Profile Synchronizer for Wayfern + - 20 cloud profile backup (cloud sync via donutbrowser.com) + - Commercial use license + + ## Team ($80/mo) — adds: + - 100 cloud profile sync + - Team collaboration, profile sharing, unlimited seats + + # ANTI-PATTERNS + + - **Regression**: user explicitly mentions a previous version that worked + differently ("worked in 0.21", "went from 2 to 8 false positives"). Do NOT + dismiss as "known issue" / "expected" / "false positive in Tauri apps". Ask + which exact version was the last working one and what changed. + - **Out-of-scope (upstream Camoufox)**: report is about Camoufox's own + behavior. Redirect, do not collect logs. + - **Fork-support request**: asks the maintainer to support an alternative + Wayfern/Camoufox fork. Acknowledge in one neutral sentence — do NOT call it + "clear", "reasonable", "well-thought-out", etc. + - **AI-generated / template-violating report**: report doesn't follow the + template, may cite "official documentation" via context7, deepwiki, or any + non-`donutbrowser.com` / non-`github.com/zhom` URL. The only authoritative + sources are this GitHub repo and donutbrowser.com. + - **Speculation about internals**: never write a "Possible cause" / "Likely + cause" / "Root cause" section. Never cite internal file paths or line + numbers. Never speculate about how subscription / paid-plan checks work. + + # OS-SPECIFIC LOG PATHS (use ONLY the one matching the user's OS) + + - macOS: `~/Library/Logs/Donut Browser/` + - Linux: `~/.local/share/DonutBrowser/logs/` + - Windows: `%APPDATA%\DonutBrowser\logs\` + EOF + + - name: Stage 1 — Triage and file selection env: OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} run: | + # The triage call returns ONLY JSON. It classifies the issue and picks a + # short list of source files for the composer to read. PAYLOAD=$(jq -n \ + --arg model "$TRIAGE_MODEL" \ --rawfile title /tmp/issue-title.txt \ --rawfile body /tmp/issue-body.txt \ + --rawfile fields /tmp/issue-fields.json \ --rawfile files /tmp/all-source-files.txt \ + --rawfile scope /tmp/scope-and-pricing.md \ + --rawfile guidelines /tmp/repo-context.txt \ '{ - model: "anthropic/claude-opus-4.6", + model: $model, messages: [ { role: "system", - content: "You are a file selector for Donut Browser (Tauri + Next.js + Rust anti-detect browser). Given an issue and a list of source files, output ONLY the 10 most likely relevant file paths, one per line. No explanations, no numbering, just paths." + content: ("You are a triage classifier for the Donut Browser GitHub repo. Classify the issue and pick at most 20 source files for a composer to read.\n\n" + $scope + "\n\n# REPO GUIDELINES\n" + $guidelines + "\n\n# OUTPUT\nReturn ONLY valid JSON. No preamble, no code fences. Schema:\n{\n \"language\": \"en\" or ISO 639-1 code,\n \"classification\": one of [\"bug-in-scope\", \"bug-upstream-camoufox\", \"bug-template-violation\", \"feature-request\", \"fork-request\", \"regression\", \"ai-generated-junk\", \"question\", \"other\"],\n \"operating_system\": \"macos\" | \"windows\" | \"linux\" | \"unknown\",\n \"is_paid_feature\": true | false,\n \"user_followed_template\": true | false,\n \"regression_signal\": quoted user snippet or null,\n \"user_cited_external_docs\": URL string or null,\n \"files_to_read\": array of at most 20 file paths from the list,\n \"notes\": one short sentence describing what you observed\n}\n\nClassification guidance:\n- \"bug-upstream-camoufox\": Camoufox-internal behavior (rendering, dropdowns, JS, fingerprint impl). NOT how Donut launches it.\n- \"bug-template-violation\": missing or filled-in nonsense for required template fields.\n- \"ai-generated-junk\": cites fabricated 'official docs' (context7, deepwiki, non-donutbrowser URLs) or has the polished AI-spam shape (long, structured, fabricated certainty).\n- \"fork-request\": asks for support of CloverLabsAI/VulpineOS/etc. forks.\n- \"regression\": user names a prior version that worked.\n\nFile selection: pick files that an experienced reviewer would actually look at to act on this issue. If the issue is upstream-Camoufox, fork-request, or junk, set files_to_read to []. Otherwise pick concrete files relevant to the symptoms.") }, { role: "user", - content: ("Issue: " + $title + "\n\n" + $body + "\n\nFiles:\n" + $files) + content: ("Issue title: " + $title + "\n\nBody:\n" + $body + "\n\nParsed template fields:\n" + $fields + "\n\nAll source files:\n" + $files) } ] }') @@ -84,64 +198,94 @@ jobs: -H "Content-Type: application/json" \ -d "$PAYLOAD") - jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/selected-files.txt + jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/triage-raw.txt - # Read the selected files in full (skip binary files) - echo "" > /tmp/file-contents.txt - while IFS= read -r filepath; do + # Strip ```json fences if the model couldn't help itself. + sed -E 's/^```(json)?$//; s/```$//' /tmp/triage-raw.txt > /tmp/triage.json + + # Validate; if the model returned junk, fall back to a minimal stub so the + # composer still gets called and produces SOMETHING. + if ! jq -e . /tmp/triage.json >/dev/null 2>&1; then + echo "::warning::Triage returned non-JSON; using fallback classification" + cat /tmp/triage-raw.txt + jq -n '{ + language: "en", + classification: "bug-in-scope", + operating_system: "unknown", + is_paid_feature: false, + user_followed_template: true, + regression_signal: null, + user_cited_external_docs: null, + files_to_read: [], + notes: "triage call failed; defaulting" + }' > /tmp/triage.json + fi + + echo "Triage result:" + cat /tmp/triage.json + + - name: Read files chosen by triage + run: | + : > /tmp/file-context.txt + # files_to_read may be empty (e.g. upstream Camoufox) — that's fine. + jq -r '.files_to_read[]? // empty' /tmp/triage.json | while IFS= read -r filepath; do filepath=$(echo "$filepath" | xargs) [ -z "$filepath" ] && continue + # Reject paths that escape the repo or look fishy + case "$filepath" in + /*|*..*|*$'\n'*) continue ;; + esac if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then - echo "=== $filepath ===" >> /tmp/file-contents.txt - cat "$filepath" >> /tmp/file-contents.txt - echo "" >> /tmp/file-contents.txt + echo "=== $filepath ===" >> /tmp/file-context.txt + cat "$filepath" >> /tmp/file-context.txt + echo "" >> /tmp/file-context.txt fi - done < /tmp/selected-files.txt + done + # Cap total context at 100 KB to keep token cost bounded. + head -c 100000 /tmp/file-context.txt > /tmp/file-context.capped.txt + mv /tmp/file-context.capped.txt /tmp/file-context.txt + wc -c /tmp/file-context.txt - # Cap total context at 100KB - head -c 100000 /tmp/file-contents.txt > /tmp/file-context.txt - - - name: Analyze issue with AI + - name: Stage 2 — Compose response env: OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - ISSUE_TITLE: ${{ github.event.issue.title }} - ISSUE_BODY: ${{ github.event.issue.body }} ISSUE_AUTHOR: ${{ github.event.issue.user.login }} IS_FIRST_TIME: ${{ steps.check-first-time.outputs.is_first_time }} run: | GREETING="" if [ "$IS_FIRST_TIME" = "true" ]; then - GREETING='This is a first-time contributor. Start your comment with: "Thanks for opening your first issue!"' + GREETING='This is the user'\''s first issue — start the comment with "Thanks for opening your first issue!" on its own line.' fi - - printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt - printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt - printf '%s' "$ISSUE_AUTHOR" > /tmp/issue-author.txt printf '%s' "$GREETING" > /tmp/greeting.txt + printf '%s' "$ISSUE_AUTHOR" > /tmp/issue-author.txt PAYLOAD=$(jq -n \ + --arg model "$COMPOSER_MODEL" \ --rawfile title /tmp/issue-title.txt \ --rawfile body /tmp/issue-body.txt \ --rawfile author /tmp/issue-author.txt \ + --rawfile fields /tmp/issue-fields.json \ + --rawfile triage /tmp/triage.json \ --rawfile greeting /tmp/greeting.txt \ - --rawfile repo_context /tmp/repo-context.txt \ - --rawfile context /tmp/file-context.txt \ + --rawfile files /tmp/file-context.txt \ + --rawfile scope /tmp/scope-and-pricing.md \ + --rawfile guidelines /tmp/repo-context.txt \ '{ - model: "anthropic/claude-opus-4.6", + model: $model, messages: [ { role: "system", - content: ("You are a triage bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nYou have access to relevant source files for context.\n\nAnalyze the issue and produce a single comment. Your job is to collect missing information needed to diagnose the issue, NOT to guess the cause.\n\nFormat:\n\n1. One sentence acknowledging the issue.\n2. **Missing information** - Ask specific questions about what is missing from the report. Focus on reproducing the issue. Do NOT speculate about root causes or mention internal code/files — you will almost certainly be wrong without logs. Instead, ask for:\n - Exact steps to reproduce (if not provided)\n - Expected vs actual behavior (if unclear)\n - Error messages or screenshots (if not provided)\n - OS and app version (if not provided)\n - For bug reports: if logs are needed, tell the user EXACTLY how to get them:\n - macOS app logs: `~/Library/Logs/Donut Browser/`\n - Linux app logs: `~/.local/share/DonutBrowser/logs/`\n - Windows app logs: `%APPDATA%\\DonutBrowser\\logs\\`\n - Sync server logs: `docker logs ` or check the server console\n - Provide a ready-to-run shell command when possible.\n - For self-hosted sync issues: check if the user is using the latest Docker image (`docker pull donutbrowser/donut-sync:latest`).\n - Only ask for information that is actually missing. If the issue is already detailed, just acknowledge it.\n3. Suggest a label: `Label: bug` or `Label: enhancement` on its own line.\n\nRules:\n- Do NOT include a \"Possible cause\" section. Do not speculate about what code might be causing the issue.\n- Be brief and focused on collecting actionable information from the reporter.\n- If the issue already has everything needed (steps to reproduce, logs, version, OS), just acknowledge it.\n- Never exceed 15 lines.") + content: ("You are a triage assistant for Donut Browser. You compose ONE short GitHub comment in response to a freshly opened issue. The triage step has already classified the issue — use the classification verbatim, do not re-litigate it.\n\n" + $scope + "\n\n# REPO GUIDELINES\n" + $guidelines + "\n\n# RULES — STRICT\n\n## Output shape\n- One sentence acknowledging the report.\n- Then **Missing information** — only if there is anything actually missing. Skip this section if the user already provided OS, version, browser, repro steps, and any logs the situation calls for.\n- Maximum 15 lines.\n- No labels, no `Label:` line, no markdown headings other than `**Missing information**`.\n- No closing pleasantries (\"please let me know\", \"happy to help\", etc.).\n\n## Forbidden — never do these\n- NEVER include a `Possible cause` / `Likely cause` / `Root cause` / `Probably caused by` section. You don't have enough information; speculation is always wrong here.\n- NEVER cite internal file paths or line numbers in the comment. Internal references rot and confuse non-developers.\n- NEVER reference how subscription / paid-plan checks work internally. You don't know whether the user'\''s claim is correct.\n- NEVER call a report \"well-documented\", \"well-structured\", \"clear\", \"thorough\", \"reasonable\", \"well-thought-out\", or any similar evaluation. You are triage, not peer review.\n- NEVER list more than one OS log path. Use ONLY the path matching the user'\''s reported OS. If OS is unknown, ask for it instead of listing all three.\n- NEVER validate a feature request as \"a clear enhancement\" / \"a reasonable request\" / similar. Acknowledge neutrally and ask only the missing info (use case, urgency).\n- NEVER call a report \"a known and expected behavior\" or \"a false positive\" if the user mentions a regression. The triage tells you when this applies.\n\n## Classification handling\nThe triage classification (`triage.classification`) determines the response shape:\n\n- `bug-in-scope`: ask for what'\''s missing using the user'\''s reported OS log path. Be concrete about how to obtain logs.\n- `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.\n- `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.\n- `feature-request`: one neutral sentence acknowledging, then ask only what'\''s genuinely needed (concrete use case, whether a workaround would suffice). Do NOT validate.\n- `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.\n- `regression`: do NOT call known/expected. Ask which exact previous version was the last working one, what changed in the user'\''s environment between then and now, and the specific delta in symptoms.\n- `question`: answer briefly if obvious from repo guidelines / pricing; otherwise ask for clarification.\n\n## Paid-feature awareness\nIf `triage.is_paid_feature` is true, factor the pricing tiers into your reply. For Pro-only features (browser manipulation API/MCP, cross-OS fingerprinting, Wayfern Profile Synchronizer, cloud sync), confirm the user is logged in with an active subscription before asking for logs. If the issue is about cloud sync, mention that self-hosting `donut-sync` makes sync free and is a viable alternative.\n\n## Language\nIf the issue body is not in English, write the comment in English (the maintainer reads English). The FIRST line must politely ask the user to communicate in English so the maintainer can help. Then continue with the normal triage response, in English.\n\n## OS-specific log paths\nUse ONLY the one matching `triage.operating_system`:\n - macos: `~/Library/Logs/Donut Browser/`\n - linux: `~/.local/share/DonutBrowser/logs/`\n - windows: `%APPDATA%\\DonutBrowser\\logs\\` (PowerShell-friendly: `Get-ChildItem $env:APPDATA\\DonutBrowser\\logs`)\n - unknown: ask the user to share their OS first.") }, { role: "user", - content: ( - (if ($greeting | length) > 0 then $greeting + "\n\n" else "" end) + - "Analyze this issue:\n\nTitle: " + $title + + content: ((if ($greeting | length) > 0 then $greeting + "\n\n" else "" end) + + "Title: " + $title + "\nAuthor: " + $author + - "\n\nBody:\n" + $body + - "\n\nRelevant source files:\n" + $context - ) + "\n\n## Triage result\n" + $triage + + "\n\n## Parsed template fields\n" + $fields + + "\n\n## Raw issue body\n" + $body + + "\n\n## Source files (selected by triage)\n" + $files) } ] }') @@ -154,28 +298,41 @@ jobs: jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/ai-comment.txt if [ ! -s /tmp/ai-comment.txt ]; then - echo "::error::AI response was empty" + echo "::error::Composer returned empty response" echo "Raw response:" echo "$RESPONSE" exit 1 fi - - name: Post comment and label + - name: Strip forbidden sections (defense in depth) + run: | + # Even with explicit prompt rules, LLMs sometimes still emit "Possible cause" + # and friends. Strip any such heading + its block. Also drop any stray + # `Label:` lines from earlier prompt iterations. + python3 - <<'EOF' + import re + path = '/tmp/ai-comment.txt' + text = open(path).read() + # Drop forbidden section headers and everything until a blank line or another header. + forbidden = re.compile( + r'^\s*\**\s*(?:possible|likely|root|probable)\s+cause\b.*?(?=^\s*$|\n##|\n\*\*[A-Z]|\Z)', + re.IGNORECASE | re.MULTILINE | re.DOTALL, + ) + text = forbidden.sub('', text) + # Drop stale Label: lines (we don't label anymore). + text = re.sub(r'^\s*Label:\s*.*$', '', text, flags=re.MULTILINE) + # Collapse 3+ blank lines. + text = re.sub(r'\n{3,}', '\n\n', text).strip() + '\n' + open(path, 'w').write(text) + EOF + + - name: Post comment (no labeling) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} run: | - LABEL=$(grep -oP '^Label:\s*\K.*' /tmp/ai-comment.txt | tail -1 | tr '[:upper:]' '[:lower:]' | xargs) - sed -i '/^Label:/d' /tmp/ai-comment.txt - gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt - if [ "$LABEL" = "bug" ]; then - gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "bug" 2>/dev/null || true - elif [ "$LABEL" = "enhancement" ]; then - gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "enhancement" 2>/dev/null || true - fi - analyze-pr: if: github.repository == 'zhom/donutbrowser' && github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' runs-on: ubuntu-latest @@ -204,26 +361,20 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | - # Get changed files list gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" \ --jq '.[] | "- \(.filename) (\(.status)) +\(.additions)/-\(.deletions)"' \ > /tmp/pr-files.txt - # Get the actual diff gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \ --header "Accept: application/vnd.github.diff" \ > /tmp/pr-diff-full.txt 2>/dev/null || true head -c 20000 /tmp/pr-diff-full.txt > /tmp/pr-diff.txt - # Get CONTRIBUTING.md and README.md for context cat CONTRIBUTING.md > /tmp/contributing.txt 2>/dev/null || echo "Not found" > /tmp/contributing.txt head -50 README.md > /tmp/readme.txt 2>/dev/null || echo "Not found" > /tmp/readme.txt - - # Read project guidelines (contains repo structure) cp CLAUDE.md /tmp/repo-context.txt - # Read full contents of all changed files (skip binary) - echo "" > /tmp/related-file-contents.txt + : > /tmp/related-file-contents.txt gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' | while IFS= read -r filepath; do if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then echo "=== $filepath (full file) ===" >> /tmp/related-file-contents.txt @@ -258,6 +409,7 @@ jobs: printf '%s' "$GREETING" > /tmp/greeting.txt PAYLOAD=$(jq -n \ + --arg model "$COMPOSER_MODEL" \ --rawfile title /tmp/pr-title.txt \ --rawfile body /tmp/pr-body.txt \ --rawfile author /tmp/pr-author.txt \ @@ -270,7 +422,7 @@ jobs: --rawfile contributing /tmp/contributing.txt \ --rawfile file_context /tmp/pr-file-context.txt \ '{ - model: "anthropic/claude-opus-4.6", + model: $model, messages: [ { role: "system", diff --git a/package.json b/package.json index acb2302..c33777f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "donutbrowser", "private": true, "license": "AGPL-3.0", - "version": "0.22.6", + "version": "0.22.7", "type": "module", "scripts": { "dev": "next dev --turbopack -p 12341", @@ -16,7 +16,7 @@ "lint:js": "biome check src/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit", "lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all", "lint:spell": "typos .", - "tauri": "tauri", + "tauri": "node scripts/run-with-env.mjs tauri", "shadcn:add": "pnpm dlx shadcn@latest add", "prepare": "husky && husky install", "format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all", diff --git a/scripts/run-with-env.mjs b/scripts/run-with-env.mjs new file mode 100755 index 0000000..789c026 --- /dev/null +++ b/scripts/run-with-env.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +// Wrapper that loads `.env` into process.env (without overwriting anything +// already in the environment) and execs the given command. Used by the +// `tauri` npm script so `pnpm tauri build` picks up APPLE_SIGNING_IDENTITY, +// APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID etc. without requiring direnv. +// +// Plain shell `source .env` works on macOS/Linux but not Windows; this +// wrapper is platform-agnostic. + +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const envPath = resolve(projectRoot, ".env"); + +if (existsSync(envPath)) { + const content = readFileSync(envPath, "utf8"); + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq === -1) continue; + const key = line.slice(0, eq).trim(); + let val = line.slice(eq + 1).trim(); + if ( + (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) + ) { + val = val.slice(1, -1); + } + // Don't overwrite values already exported by the parent shell — direnv + // / CI secrets / one-off `FOO=bar pnpm tauri ...` invocations win. + if (process.env[key] === undefined) { + process.env[key] = val; + } + } +} + +const [, , cmd, ...args] = process.argv; +if (!cmd) { + console.error("usage: run-with-env.mjs [args...]"); + process.exit(2); +} + +const child = spawn(cmd, args, { stdio: "inherit", shell: false }); +child.on("error", (err) => { + console.error(`Failed to spawn ${cmd}:`, err.message); + process.exit(1); +}); +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + } else { + process.exit(code ?? 1); + } +}); diff --git a/src-tauri/src/app_auto_updater.rs b/src-tauri/src/app_auto_updater.rs index 9466899..965a635 100644 --- a/src-tauri/src/app_auto_updater.rs +++ b/src-tauri/src/app_auto_updater.rs @@ -928,18 +928,35 @@ impl AppAutoUpdater { // Move new app to current location fs::rename(installer_path, ¤t_app_path)?; - // Remove quarantine attributes from the new app - let _ = Command::new("xattr") - .args([ - "-dr", - "com.apple.quarantine", - current_app_path.to_str().unwrap(), - ]) - .output(); - - let _ = Command::new("xattr") - .args(["-cr", current_app_path.to_str().unwrap()]) - .output(); + // Remove the macOS quarantine attribute from the freshly-installed app + // so Gatekeeper doesn't block its first launch — but only if it's + // actually present. macOS Sequoia's App Management TCC fires on the + // modify-class syscall regardless of whether anything is actually + // modified, so we gate the call behind a read-only `getxattr` check. + let needs_quarantine_removal = { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + let path_c = CString::new(current_app_path.as_os_str().as_bytes()).ok(); + let attr_c = CString::new("com.apple.quarantine").ok(); + match (path_c, attr_c) { + (Some(p), Some(a)) => { + // SAFETY: getxattr with a null buffer is a read-only size query. + let result = + unsafe { libc::getxattr(p.as_ptr(), a.as_ptr(), std::ptr::null_mut(), 0, 0, 0) }; + result >= 0 + } + _ => false, + } + }; + if needs_quarantine_removal { + let _ = Command::new("xattr") + .args([ + "-dr", + "com.apple.quarantine", + current_app_path.to_str().unwrap(), + ]) + .output(); + } // Clean up backup after successful installation let _ = fs::remove_dir_all(&backup_path); diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index 4cf3c16..f4e1a78 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -127,8 +127,16 @@ lazy_static! { impl CloudAuthManager { fn new() -> Self { let state = Self::load_auth_state_from_disk(); + // Bound every cloud API call so no single slow / hung request can stall + // the startup chain (sync-token → proxy-config → wayfern-token), which + // otherwise gates Wayfern launch behind whichever endpoint is slowest. + let client = Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .connect_timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap_or_else(|_| Client::new()); Self { - client: Client::new(), + client, state: Mutex::new(state), refresh_lock: tokio::sync::Mutex::new(()), wayfern_token: Mutex::new(None), @@ -990,7 +998,15 @@ impl CloudAuthManager { let token = self .api_call_with_retry(|access_token| { let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start"); - let client = reqwest::Client::new(); + // Bound the request: without a timeout, an unreachable + // api.donutbrowser.com hangs the background fetch indefinitely, + // which in turn forces wayfern_manager's launch-time wait to + // exhaust its full polling budget every time. + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(8)) + .connect_timeout(std::time::Duration::from_secs(4)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); async move { let response = client .post(&url) diff --git a/src-tauri/src/extraction.rs b/src-tauri/src/extraction.rs index 349b60d..cb4b5bb 100644 --- a/src-tauri/src/extraction.rs +++ b/src-tauri/src/extraction.rs @@ -12,6 +12,39 @@ use tokio::process::Command; #[cfg(target_os = "macos")] use std::fs::create_dir_all; +/// Returns true if `path` carries a `com.apple.quarantine` extended attribute. +/// +/// Uses `getxattr` with a null buffer to query the attribute size only — +/// this is a read-only syscall and does NOT trigger macOS Sequoia's App +/// Management TCC prompt. We use it to gate the `xattr -d` removal: macOS +/// fires the prompt on the modify-class syscall (`removexattr`) even when +/// the operation is a no-op, so skipping the call entirely when the +/// attribute is absent is the only way to stay quiet. +#[cfg(target_os = "macos")] +fn has_quarantine_attr(path: &Path) -> bool { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + let Ok(path_c) = CString::new(path.as_os_str().as_bytes()) else { + return false; + }; + let Ok(attr_c) = CString::new("com.apple.quarantine") else { + return false; + }; + // SAFETY: getxattr is a stable libc API. Passing a null buffer with size 0 + // makes it a pure read-only size query. + let result = unsafe { + libc::getxattr( + path_c.as_ptr(), + attr_c.as_ptr(), + std::ptr::null_mut(), + 0, + 0, + 0, + ) + }; + result >= 0 +} + pub struct Extractor; impl Extractor { @@ -207,18 +240,23 @@ impl Extractor { match extraction_result { Ok(path) => { - // Remove quarantine attributes on macOS to prevent - // "app was prevented from modifying data" prompts + // Remove quarantine attributes on macOS to prevent Gatekeeper prompts — + // but only if there's actually something to remove. Calling the + // modify-class `removexattr` syscall on a file without quarantine still + // fires macOS Sequoia's App Management TCC notification, so we skip + // the call entirely when the attribute is absent. #[cfg(target_os = "macos")] { - let _ = tokio::process::Command::new("xattr") - .args([ - "-dr", - "com.apple.quarantine", - dest_dir.to_str().unwrap_or("."), - ]) - .output() - .await; + if has_quarantine_attr(dest_dir) { + let _ = tokio::process::Command::new("xattr") + .args([ + "-dr", + "com.apple.quarantine", + dest_dir.to_str().unwrap_or("."), + ]) + .output() + .await; + } } log::info!( @@ -419,9 +457,15 @@ impl Extractor { log::info!("Copying .app to: {}", app_path.display()); + // `-X` strips extended attributes (notably com.apple.quarantine) during + // the copy itself. Without it, `cp -R` preserves quarantine from the + // mounted DMG, which then has to be removed with `xattr -dr` — and that + // removexattr syscall on a signed .app bundle trips macOS Sequoia's App + // Management TCC notification ("Donut.app was prevented from modifying + // apps on your Mac"). Stripping at copy time is silent. let output = Command::new("cp") .args([ - "-R", + "-RX", app_entry.to_str().unwrap(), app_path.to_str().unwrap(), ]) @@ -444,18 +488,21 @@ impl Extractor { log::info!("Successfully copied .app bundle"); - // Remove quarantine attributes - let _ = Command::new("xattr") - .args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()]) - .output() - .await; - - let _ = Command::new("xattr") - .args(["-cr", app_path.to_str().unwrap()]) - .output() - .await; - - log::info!("Removed quarantine attributes"); + // Remove the macOS quarantine attribute so Gatekeeper doesn't block launch + // — but only if it's actually present. A no-op `removexattr` syscall on a + // signed .app bundle still trips macOS Sequoia's App Management privacy + // prompt ("Donut.app was prevented from modifying apps on your Mac"), + // even when no modification actually happens, so we gate the call behind + // a read-only `getxattr` check. + if has_quarantine_attr(&app_path) { + let _ = Command::new("xattr") + .args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()]) + .output() + .await; + log::info!("Removed quarantine attributes"); + } else { + log::info!("No quarantine attribute on .app, skipping xattr removal"); + } // Unmount the DMG let output = Command::new("hdiutil") diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ffe19a9..41128af 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1888,21 +1888,31 @@ pub fn run() { // Start cloud auth background refresh loop let app_handle_cloud = app.handle().clone(); tauri::async_runtime::spawn(async move { - // On startup, refresh sync token and proxy if cloud auth is active. + // On startup, refresh sync token, proxy config, and wayfern token in + // PARALLEL. Previously they were awaited sequentially, so the wayfern + // token request didn't even start until the earlier two API calls had + // finished. Wayfern launch can race with this task — a few seconds of + // serialized API calls translates directly into a slow first launch + // because launch_wayfern blocks waiting for the token to land. // api_call_with_retry handles 401/refresh internally — no direct // refresh_access_token call needed. if cloud_auth::CLOUD_AUTH.is_logged_in().await { - if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await { - log::warn!("Failed to refresh cloud sync token on startup: {e}"); - } - cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await; - - // Request wayfern token on startup for paid users - if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await { - if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await { - log::warn!("Failed to request wayfern token on startup: {e}"); + let sync_token_fut = async { + if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await { + log::warn!("Failed to refresh cloud sync token on startup: {e}"); } - } + }; + let proxy_fut = async { + cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await; + }; + let wayfern_fut = async { + if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await { + if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await { + log::warn!("Failed to request wayfern token on startup: {e}"); + } + } + }; + tokio::join!(sync_token_fut, proxy_fut, wayfern_fut); } cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await; }); diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index 34d15fb..07cc0a0 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -174,6 +174,10 @@ pub struct ProxyManager { // Track active proxy IDs by profile name for targeted cleanup profile_active_proxy_ids: Mutex>, // Maps profile name to proxy id stored_proxies: Mutex>, // Maps proxy ID to stored proxy + // Consecutive cleanup passes during which a browser PID looked dead. + // We only reap a worker after it has been missed in N consecutive scans — + // a single sysinfo blip under load shouldn't kill a still-running worker. + dead_browser_misses: Mutex>, } impl ProxyManager { @@ -183,6 +187,7 @@ impl ProxyManager { profile_proxies: Mutex::new(HashMap::new()), profile_active_proxy_ids: Mutex::new(HashMap::new()), stored_proxies: Mutex::new(HashMap::new()), + dead_browser_misses: Mutex::new(HashMap::new()), }; // Load stored proxies on initialization @@ -2095,17 +2100,52 @@ impl ProxyManager { sysinfo::RefreshKind::nothing().with_processes(sysinfo::ProcessRefreshKind::everything()), ); - let dead_browser_entries: Vec<(u32, String, Option)> = snapshot - .into_iter() - .filter(|(browser_pid, _, _)| { - // The sentinel PID=0 is used as a placeholder during launch, - // before update_proxy_pid has recorded the real browser PID. - *browser_pid != 0 - && system - .process(sysinfo::Pid::from_u32(*browser_pid)) - .is_none() - }) - .collect(); + // Two-state classification: alive PIDs reset their miss counter, + // dead PIDs increment it. A worker is only reaped after MISS_THRESHOLD + // consecutive misses (~60s by default given the 30s cleanup cadence), + // so a single sysinfo blip under heavy load doesn't kill a healthy worker. + const MISS_THRESHOLD: u8 = 2; + + let mut alive_pids: Vec = Vec::new(); + let mut dead_candidates: Vec<(u32, String, Option)> = Vec::new(); + let mut snapshot_pids: std::collections::HashSet = std::collections::HashSet::new(); + for (browser_pid, proxy_id, profile_id) in snapshot { + snapshot_pids.insert(browser_pid); + // The sentinel PID=0 is used as a placeholder during launch, + // before update_proxy_pid has recorded the real browser PID. + if browser_pid == 0 { + continue; + } + if system + .process(sysinfo::Pid::from_u32(browser_pid)) + .is_some() + { + alive_pids.push(browser_pid); + } else { + dead_candidates.push((browser_pid, proxy_id, profile_id)); + } + } + + let dead_browser_entries: Vec<(u32, String, Option)> = { + let mut misses = self.dead_browser_misses.lock().unwrap(); + // Forget PIDs no longer tracked at all (worker already torn down elsewhere). + misses.retain(|pid, _| snapshot_pids.contains(pid)); + // Reset miss count for any PID that's currently alive. + for pid in &alive_pids { + misses.remove(pid); + } + // Increment dead candidates and select those past threshold. + let mut to_reap = Vec::new(); + for (browser_pid, proxy_id, profile_id) in dead_candidates { + let count = misses.entry(browser_pid).or_insert(0); + *count = count.saturating_add(1); + if *count >= MISS_THRESHOLD { + misses.remove(&browser_pid); + to_reap.push((browser_pid, proxy_id, profile_id)); + } + } + to_reap + }; for (browser_pid, proxy_id, profile_id) in dead_browser_entries { log::info!( diff --git a/src-tauri/src/proxy_server.rs b/src-tauri/src/proxy_server.rs index 5ad45fb..6388de6 100644 --- a/src-tauri/src/proxy_server.rs +++ b/src-tauri/src/proxy_server.rs @@ -16,7 +16,6 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::task::{Context, Poll}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; -use tokio::net::TcpListener; use tokio::net::TcpStream; /// Combined read+write trait for tunnel target streams, allowing @@ -1232,8 +1231,49 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box match socket.listen(1024) { + Ok(l) => break l, + Err(e) if attempts < 5 => { + attempts += 1; + let delay = std::time::Duration::from_millis(200 * u64::from(attempts)); + log::warn!( + "listen() on {} failed (attempt {}/5): {}, retrying in {}ms", + bind_addr, + attempts, + e, + delay.as_millis() + ); + tokio::time::sleep(delay).await; + } + Err(e) => { + return Err(format!("Failed to listen on {bind_addr} after 5 attempts: {e}").into()) + } + }, + Err(e) if attempts < 5 => { + attempts += 1; + let delay = std::time::Duration::from_millis(200 * u64::from(attempts)); + log::warn!( + "bind() on {} failed (attempt {}/5): {}, retrying in {}ms", + bind_addr, + attempts, + e, + delay.as_millis() + ); + tokio::time::sleep(delay).await; + } + Err(e) => return Err(format!("Failed to bind {bind_addr} after 5 attempts: {e}").into()), + } + } + }; let actual_port = listener.local_addr()?.port(); log::error!("Successfully bound to port {}", actual_port); @@ -1295,52 +1335,54 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box 0 || requests > 0; + // Catch panics so a poisoned lock or unexpected error inside + // flush_to_disk doesn't abort the flush task and leave stats + // unwritten for the lifetime of the worker. The captured state + // is all Copy or atomic-assignment, so AssertUnwindSafe is sound. + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if let Some(tracker) = get_traffic_tracker() { + let (sent, recv, requests) = tracker.get_snapshot(); + let current_bytes = sent + recv; + let time_since_activity = last_activity_time.elapsed(); + let time_since_flush = last_flush_time.elapsed(); + let has_traffic = current_bytes > 0 || requests > 0; - // Determine flush frequency based on activity - // When active: flush every 5 seconds - // When idle: flush every 30 seconds - let desired_interval_secs = - if has_traffic || time_since_activity < std::time::Duration::from_secs(30) { - 5u64 - } else { - 30u64 - }; + let desired_interval_secs = + if has_traffic || time_since_activity < std::time::Duration::from_secs(30) { + 5u64 + } else { + 30u64 + }; - // Update interval if needed - if desired_interval_secs != current_interval_secs { - current_interval_secs = desired_interval_secs; - interval = tokio::time::interval(tokio::time::Duration::from_secs(desired_interval_secs)); - } + if desired_interval_secs != current_interval_secs { + current_interval_secs = desired_interval_secs; + interval = + tokio::time::interval(tokio::time::Duration::from_secs(desired_interval_secs)); + } - // Only flush if enough time has passed since last flush - let flush_interval = std::time::Duration::from_secs(desired_interval_secs); - let should_flush = time_since_flush >= flush_interval; + let flush_interval = std::time::Duration::from_secs(desired_interval_secs); + let should_flush = time_since_flush >= flush_interval; - if should_flush { - match tracker.flush_to_disk() { - Ok(Some((sent, recv))) => { - // Successful flush with data - last_flush_time = std::time::Instant::now(); - if sent > 0 || recv > 0 { - last_activity_time = std::time::Instant::now(); + if should_flush { + match tracker.flush_to_disk() { + Ok(Some((sent, recv))) => { + last_flush_time = std::time::Instant::now(); + if sent > 0 || recv > 0 { + last_activity_time = std::time::Instant::now(); + } + } + Ok(None) => { + last_flush_time = std::time::Instant::now(); + } + Err(e) => { + log::error!("Failed to flush traffic stats: {}", e); } - } - Ok(None) => { - // No data to flush - this is normal - last_flush_time = std::time::Instant::now(); - } - Err(e) => { - log::error!("Failed to flush traffic stats: {}", e); - // Don't update flush time on error - retry sooner } } } + })); + if let Err(panic) = result { + log::error!("Panic caught in proxy traffic flush task; continuing: {panic:?}"); } } }); diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index b291c58..bfbf1e1 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -639,14 +639,25 @@ impl WayfernManager { .has_active_paid_subscription() .await { - log::info!("Wayfern token not ready for paid user, waiting..."); - for _ in 0..15 { + // Brief wait for the background token fetch — when the API is healthy + // the token usually lands in well under a second. If api.donutbrowser.com + // is unreachable we don't want to gate the whole launch on it; the + // browser still works without the token (cross-OS fingerprinting just + // won't be enabled for this session, and the next launch will pick it + // up once the token arrives). + log::info!("Wayfern token not ready for paid user, waiting briefly..."); + for _ in 0..3 { tokio::time::sleep(Duration::from_secs(1)).await; wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await; if wayfern_token.is_some() { break; } } + if wayfern_token.is_none() { + log::warn!( + "Wayfern token still unavailable after wait; launching without it (api.donutbrowser.com may be unreachable)" + ); + } } if let Some(ref token) = wayfern_token { args.push(format!("--wayfern-token={token}")); diff --git a/src/app/page.tsx b/src/app/page.tsx index 6c145c0..cfed694 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -12,6 +12,7 @@ import { CookieCopyDialog } from "@/components/cookie-copy-dialog"; import { CookieManagementDialog } from "@/components/cookie-management-dialog"; import { CreateProfileDialog } from "@/components/create-profile-dialog"; import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"; +import { DeviceCodeVerifyDialog } from "@/components/device-code-verify-dialog"; import { ExtensionGroupAssignmentDialog } from "@/components/extension-group-assignment-dialog"; import { ExtensionManagementDialog } from "@/components/extension-management-dialog"; import { GroupAssignmentDialog } from "@/components/group-assignment-dialog"; @@ -197,6 +198,7 @@ export default function Home() { useState(false); const [isBulkDeleting, setIsBulkDeleting] = useState(false); const [syncConfigDialogOpen, setSyncConfigDialogOpen] = useState(false); + const [deviceCodeDialogOpen, setDeviceCodeDialogOpen] = useState(false); const [syncAllDialogOpen, setSyncAllDialogOpen] = useState(false); const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false); const [currentProfileForSync, setCurrentProfileForSync] = @@ -394,21 +396,32 @@ export default function Home() { } }, [isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized]); - const checkNextPermission = useCallback(() => { - try { - if (!isMicrophoneAccessGranted) { - setCurrentPermissionType("microphone"); - setPermissionDialogOpen(true); - } else if (!isCameraAccessGranted) { - setCurrentPermissionType("camera"); - setPermissionDialogOpen(true); - } else { - setPermissionDialogOpen(false); + const checkNextPermission = useCallback( + (justGranted?: PermissionType) => { + try { + // Treat the just-granted permission as already granted even if our + // own usePermissions instance hasn't observed it yet — it polls on a + // 5 s cadence and would otherwise leave the dialog stuck on the + // permission the user just successfully granted. + const micGranted = + isMicrophoneAccessGranted || justGranted === "microphone"; + const camGranted = isCameraAccessGranted || justGranted === "camera"; + + if (!micGranted) { + setCurrentPermissionType("microphone"); + setPermissionDialogOpen(true); + } else if (!camGranted) { + setCurrentPermissionType("camera"); + setPermissionDialogOpen(true); + } else { + setPermissionDialogOpen(false); + } + } catch (error) { + console.error("Failed to check next permission:", error); } - } catch (error) { - console.error("Failed to check next permission:", error); - } - }, [isMicrophoneAccessGranted, isCameraAccessGranted]); + }, + [isMicrophoneAccessGranted, isCameraAccessGranted], + ); const listenForUrlEvents = useCallback(async () => { try { @@ -1316,8 +1329,29 @@ export default function Home() { setSyncAllDialogOpen(true); } }} + onLoginStarted={() => { + // Hand the verify step off to its own dialog. We close this one + // first so the verify dialog isn't stacked on top of it (and + // can't end up stacked on top of the profile selector either). + setSyncConfigDialogOpen(false); + setDeviceCodeDialogOpen(true); + }} /> + {/* Only render while no profile-selector flow is in progress, so the + verify dialog never lands on top of a deep-link-triggered selector. */} + {pendingUrls.length === 0 && ( + { + setDeviceCodeDialogOpen(false); + if (loginOccurred) { + setSyncAllDialogOpen(true); + } + }} + /> + )} + { diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index a85d129..8842f33 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -537,7 +537,7 @@ export function CreateProfileDialog({ return ( - + {currentStep === "browser-selection" diff --git a/src/components/device-code-verify-dialog.tsx b/src/components/device-code-verify-dialog.tsx new file mode 100644 index 0000000..f96f4d5 --- /dev/null +++ b/src/components/device-code-verify-dialog.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LoadingButton } from "@/components/loading-button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useCloudAuth } from "@/hooks/use-cloud-auth"; +import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; + +interface DeviceCodeVerifyDialogProps { + isOpen: boolean; + onClose: (loginOccurred?: boolean) => void; +} + +/** + * Dedicated dialog for pasting and verifying the cloud device-link code. + * Opens after the user clicks "Login" in the sync config dialog so the + * verify step is a focused step on its own — and so it doesn't visually + * stack with other dialogs (e.g. the profile selector triggered by a + * deep link) sharing the same view. + */ +export function DeviceCodeVerifyDialog({ + isOpen, + onClose, +}: DeviceCodeVerifyDialogProps) { + const { t } = useTranslation(); + const { exchangeDeviceCode } = useCloudAuth(); + const [linkCode, setLinkCode] = useState(""); + const [isVerifying, setIsVerifying] = useState(false); + + // Reset the field when the dialog reopens so a stale code from a + // previous attempt doesn't auto-populate. + useEffect(() => { + if (isOpen) { + setLinkCode(""); + } + }, [isOpen]); + + const handleVerify = async () => { + const trimmed = linkCode.trim(); + if (!trimmed) return; + setIsVerifying(true); + try { + await exchangeDeviceCode(trimmed); + showSuccessToast(t("sync.cloud.loginSuccess")); + try { + await invoke("restart_sync_service"); + } catch (e) { + console.error("Failed to restart sync service:", e); + } + onClose(true); + } catch (error) { + console.error("Device-code exchange failed:", error); + showErrorToast(String(error)); + } finally { + setIsVerifying(false); + } + }; + + return ( + { + if (!open) onClose(false); + }} + > + + + {t("sync.cloud.verifyAndLogin")} + + {t("sync.cloud.deviceLinkInstructions")} + + +
+
+ + { + setLinkCode(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && linkCode.trim()) { + void handleVerify(); + } + }} + autoComplete="off" + spellCheck={false} + autoFocus + /> + void handleVerify()} + isLoading={isVerifying} + disabled={!linkCode.trim()} + className="w-full" + > + {isVerifying + ? t("sync.cloud.loggingIn") + : t("sync.cloud.verifyAndLogin")} + +
+
+
+
+ ); +} diff --git a/src/components/home-header.tsx b/src/components/home-header.tsx index 7fe2b5f..c5bf097 100644 --- a/src/components/home-header.tsx +++ b/src/components/home-header.tsx @@ -272,7 +272,7 @@ const HomeHeader = ({ diff --git a/src/components/permission-dialog.tsx b/src/components/permission-dialog.tsx index 752b572..6184168 100644 --- a/src/components/permission-dialog.tsx +++ b/src/components/permission-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { BsCamera, BsMic } from "react-icons/bs"; import { LoadingButton } from "@/components/loading-button"; @@ -21,7 +21,14 @@ interface PermissionDialogProps { isOpen: boolean; onClose: () => void; permissionType: PermissionType; - onPermissionGranted?: () => void; + /** + * Fired when the displayed permission becomes granted. The just-granted + * type is passed through so the parent can act optimistically — its own + * usePermissions instance polls on a 5 s cadence and would otherwise be + * stale right after the macOS system prompt is accepted, leaving the + * dialog open in a confusing state. + */ + onPermissionGranted?: (justGranted: PermissionType) => void; } export function PermissionDialog({ @@ -32,6 +39,7 @@ export function PermissionDialog({ }: PermissionDialogProps) { const { t } = useTranslation(); const [isRequesting, setIsRequesting] = useState(false); + const [isWaitingForGrant, setIsWaitingForGrant] = useState(false); const [isMacOS, setIsMacOS] = useState(false); const { requestPermission, @@ -57,12 +65,68 @@ export function PermissionDialog({ ? isMicrophoneAccessGranted : isCameraAccessGranted; - // Auto-close dialog when permission is granted + // Mirror the latest permission state into a ref so the deferred timeout + // callback can read it without being recreated on every state change. + const isCurrentPermissionGrantedRef = useRef(isCurrentPermissionGranted); useEffect(() => { - if (isCurrentPermissionGranted && isOpen) { - onPermissionGranted?.(); + isCurrentPermissionGrantedRef.current = isCurrentPermissionGranted; + }, [isCurrentPermissionGranted]); + + // When the permission becomes granted, fire a success toast and let the + // parent decide what to do next (progress to the other permission, or close). + // We deliberately do NOT keep the dialog around to show a "Done" state — + // the toast is the confirmation, and the dialog closes immediately. + // Use a ref to ensure we only fire the toast once per grant transition. + const grantedToastFiredForRef = useRef(null); + useEffect(() => { + if (!isOpen) { + grantedToastFiredForRef.current = null; + return; } - }, [isCurrentPermissionGranted, isOpen, onPermissionGranted]); + if ( + isCurrentPermissionGranted && + grantedToastFiredForRef.current !== permissionType + ) { + grantedToastFiredForRef.current = permissionType; + showSuccessToast( + permissionType === "microphone" + ? t("permissionDialog.grantedToastMicrophone") + : t("permissionDialog.grantedToastCamera"), + ); + onPermissionGranted?.(permissionType); + } + }, [ + isCurrentPermissionGranted, + isOpen, + onPermissionGranted, + permissionType, + t, + ]); + + // Pending-grant timeout: triggered after the user clicks "Grant Access" + // to give the macOS permission state a few seconds to propagate to our poll. + const waitTimeoutRef = useRef | null>(null); + + // If permission becomes granted during the wait window, end the wait early. + useEffect(() => { + if (isWaitingForGrant && isCurrentPermissionGranted) { + if (waitTimeoutRef.current) { + clearTimeout(waitTimeoutRef.current); + waitTimeoutRef.current = null; + } + setIsWaitingForGrant(false); + } + }, [isWaitingForGrant, isCurrentPermissionGranted]); + + // Clear any pending timeout on unmount. + useEffect(() => { + return () => { + if (waitTimeoutRef.current) { + clearTimeout(waitTimeoutRef.current); + waitTimeoutRef.current = null; + } + }; + }, []); const getPermissionIcon = (type: PermissionType) => { switch (type) { @@ -95,11 +159,25 @@ export function PermissionDialog({ setIsRequesting(true); try { await requestPermission(permissionType); - showSuccessToast( - permissionType === "microphone" - ? t("permissionDialog.requestSuccessMicrophone") - : t("permissionDialog.requestSuccessCamera"), - ); + // The macOS permission poll runs every 5 s, so the new state can take + // a moment to surface. Keep the grant button in its busy state for + // that window so the user has clear feedback, and notify them if the + // grant still hasn't landed by the end. + setIsWaitingForGrant(true); + if (waitTimeoutRef.current) { + clearTimeout(waitTimeoutRef.current); + } + waitTimeoutRef.current = setTimeout(() => { + waitTimeoutRef.current = null; + setIsWaitingForGrant(false); + if (!isCurrentPermissionGrantedRef.current) { + showErrorToast( + permissionType === "microphone" + ? t("permissionDialog.stillNotGrantedMicrophone") + : t("permissionDialog.stillNotGrantedCamera"), + ); + } + }, 5000); } catch (error) { console.error("Failed to request permission:", error); showErrorToast(t("permissionDialog.requestFailed")); @@ -129,16 +207,6 @@ export function PermissionDialog({
- {isCurrentPermissionGranted && ( -
-

- {permissionType === "microphone" - ? t("permissionDialog.grantedMicrophone") - : t("permissionDialog.grantedCamera")} -

-
- )} - {!isCurrentPermissionGranted && (

@@ -151,15 +219,17 @@ export function PermissionDialog({

- - {isCurrentPermissionGranted - ? t("permissionDialog.doneButton") - : t("permissionDialog.cancelButton")} + + {t("permissionDialog.cancelButton")} {!isCurrentPermissionGranted && ( { handleRequestPermission().catch((err: unknown) => { console.error(err); diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index c8c9547..c8b6062 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -907,6 +907,13 @@ export function ProfilesDataTable({ } setRowSelection(newSelection); prevSelectedProfilesRef.current = selectedProfiles; + // When the parent clears the selection (e.g. after a bulk action like + // delete / move-to-group), collapse the checkbox column back to icons. + // Otherwise the row checkboxes stay visible and only revert after the + // user clicks one — which the per-checkbox handler resets. + if (selectedProfiles.length === 0) { + setShowCheckboxes(false); + } } }, [selectedProfiles]); diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 59db349..e4adba2 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -408,7 +408,12 @@ export function SettingsDialog({ // Update settings with any generated tokens setSettings(savedSettings); settingsToSave = savedSettings; - setTheme(settings.theme === "custom" ? "dark" : settings.theme); + // Pass the actual theme value through. Calling setTheme("dark") here + // when the user is on "custom" pushes the provider state to "dark", + // which triggers its clear-custom-vars effect and wipes the CSS + // variables we set just below — that's the bug where saving a custom + // theme made it disappear until the app was restarted. + setTheme(settings.theme); // Apply or clear custom variables only on Save if (settings.theme === "custom") { @@ -539,7 +544,7 @@ export function SettingsDialog({ checkDefaultBrowserStatus().catch((err: unknown) => { console.error(err); }); - }, 500); // Check every 500ms + }, 2000); // Cleanup interval on component unmount or dialog close return () => { diff --git a/src/components/sync-config-dialog.tsx b/src/components/sync-config-dialog.tsx index c9b0ae0..1020254 100644 --- a/src/components/sync-config-dialog.tsx +++ b/src/components/sync-config-dialog.tsx @@ -32,6 +32,14 @@ const DEVICE_LINK_URL = "https://donutbrowser.com/auth/link"; interface SyncConfigDialogProps { isOpen: boolean; onClose: (loginOccurred?: boolean) => void; + /** + * Called after the user clicks "Login" so the parent can open the + * device-code verify dialog as a separate step. Implementations should + * close this dialog and open the verify one — that keeps the verify + * step visually independent and avoids stacking on top of other + * dialogs (e.g. the profile selector triggered by deep links). + */ + onLoginStarted?: () => void; } interface ProxyUsage { @@ -42,7 +50,11 @@ interface ProxyUsage { extra_limit_mb: number; } -export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { +export function SyncConfigDialog({ + isOpen, + onClose, + onLoginStarted, +}: SyncConfigDialogProps) { const { t } = useTranslation(); // Self-hosted state @@ -58,11 +70,8 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { user, isLoggedIn, isLoading: isCloudLoading, - exchangeDeviceCode, logout, } = useCloudAuth(); - const [linkCode, setLinkCode] = useState(""); - const [isVerifying, setIsVerifying] = useState(false); const [activeTab, setActiveTab] = useState("cloud"); const [, setLiveProxyUsage] = useState(null); @@ -103,7 +112,6 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { if (isOpen) { setConnectionStatus("unknown"); void loadSettings(); - setLinkCode(""); void invoke("cloud_get_proxy_usage") .then(setLiveProxyUsage) .catch(() => { @@ -199,32 +207,15 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { const handleOpenLogin = useCallback(async () => { try { await invoke("handle_url_open", { url: DEVICE_LINK_URL }); + // Hand off the verify step to its own dialog so the user has a + // focused place to paste the code, and so it doesn't visually + // stack with this dialog or any other modal currently on screen. + onLoginStarted?.(); } catch (error) { console.error("Failed to open login link:", error); showErrorToast(String(error)); } - }, []); - - const handleVerifyCode = useCallback(async () => { - const trimmed = linkCode.trim(); - if (!trimmed) return; - setIsVerifying(true); - try { - await exchangeDeviceCode(trimmed); - showSuccessToast(t("sync.cloud.loginSuccess")); - try { - await invoke("restart_sync_service"); - } catch (e) { - console.error("Failed to restart sync service:", e); - } - onClose(true); - } catch (error) { - console.error("Device-code exchange failed:", error); - showErrorToast(String(error)); - } finally { - setIsVerifying(false); - } - }, [linkCode, exchangeDeviceCode, t, onClose]); + }, [onLoginStarted]); const handleCloudLogout = useCallback(async () => { try { @@ -375,37 +366,6 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) { > {t("sync.cloud.openLogin")} - -
- - { - setLinkCode(e.target.value); - }} - onKeyDown={(e) => { - if (e.key === "Enter" && linkCode.trim()) { - void handleVerifyCode(); - } - }} - autoComplete="off" - spellCheck={false} - /> - void handleVerifyCode()} - isLoading={isVerifying} - disabled={!linkCode.trim()} - className="w-full" - > - {isVerifying - ? t("sync.cloud.loggingIn") - : t("sync.cloud.verifyAndLogin")} - -
)} diff --git a/src/hooks/use-permissions.ts b/src/hooks/use-permissions.ts index 9decff7..624ef9a 100644 --- a/src/hooks/use-permissions.ts +++ b/src/hooks/use-permissions.ts @@ -159,7 +159,7 @@ export function usePermissions(): UsePermissionsReturn { intervalRef.current = setInterval(() => { void checkPermissions(); - }, 500); + }, 5000); return () => { if (intervalRef.current) { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 119ea53..60eef83 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1442,7 +1442,11 @@ "grantAccessButton": "Grant Access", "requestSuccessMicrophone": "Microphone Access permission requested", "requestSuccessCamera": "Camera Access permission requested", - "requestFailed": "Failed to request permission" + "requestFailed": "Failed to request permission", + "stillNotGrantedMicrophone": "Microphone access still hasn't been granted. You may need to enable it manually in System Settings → Privacy & Security → Microphone.", + "stillNotGrantedCamera": "Camera access still hasn't been granted. You may need to enable it manually in System Settings → Privacy & Security → Camera.", + "grantedToastMicrophone": "Microphone access granted", + "grantedToastCamera": "Camera access granted" }, "traffic": { "title": "Traffic Details", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3d1b2c3..9372610 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1442,7 +1442,11 @@ "grantAccessButton": "Conceder acceso", "requestSuccessMicrophone": "Acceso al micrófono solicitado", "requestSuccessCamera": "Acceso a la cámara solicitado", - "requestFailed": "Error al solicitar el permiso" + "requestFailed": "Error al solicitar el permiso", + "stillNotGrantedMicrophone": "El acceso al micrófono aún no se ha concedido. Puede que tengas que habilitarlo manualmente en Configuración del Sistema → Privacidad y Seguridad → Micrófono.", + "stillNotGrantedCamera": "El acceso a la cámara aún no se ha concedido. Puede que tengas que habilitarlo manualmente en Configuración del Sistema → Privacidad y Seguridad → Cámara.", + "grantedToastMicrophone": "Acceso al micrófono concedido", + "grantedToastCamera": "Acceso a la cámara concedido" }, "traffic": { "title": "Detalles de tráfico", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index c109330..a798205 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1442,7 +1442,11 @@ "grantAccessButton": "Accorder l'accès", "requestSuccessMicrophone": "Accès au microphone demandé", "requestSuccessCamera": "Accès à la caméra demandé", - "requestFailed": "Échec de la demande de permission" + "requestFailed": "Échec de la demande de permission", + "stillNotGrantedMicrophone": "L'accès au microphone n'a toujours pas été accordé. Vous devrez peut-être l'activer manuellement dans Réglages Système → Confidentialité et sécurité → Microphone.", + "stillNotGrantedCamera": "L'accès à la caméra n'a toujours pas été accordé. Vous devrez peut-être l'activer manuellement dans Réglages Système → Confidentialité et sécurité → Caméra.", + "grantedToastMicrophone": "Accès au microphone accordé", + "grantedToastCamera": "Accès à la caméra accordé" }, "traffic": { "title": "Détails du trafic", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0428997..887e07b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1442,7 +1442,11 @@ "grantAccessButton": "アクセスを許可", "requestSuccessMicrophone": "マイクアクセスをリクエストしました", "requestSuccessCamera": "カメラアクセスをリクエストしました", - "requestFailed": "許可のリクエストに失敗しました" + "requestFailed": "許可のリクエストに失敗しました", + "stillNotGrantedMicrophone": "マイクへのアクセスはまだ許可されていません。システム設定 → プライバシーとセキュリティ → マイク で手動で有効にする必要があるかもしれません。", + "stillNotGrantedCamera": "カメラへのアクセスはまだ許可されていません。システム設定 → プライバシーとセキュリティ → カメラ で手動で有効にする必要があるかもしれません。", + "grantedToastMicrophone": "マイクへのアクセスが許可されました", + "grantedToastCamera": "カメラへのアクセスが許可されました" }, "traffic": { "title": "トラフィックの詳細", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 86551f2..7819307 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1442,7 +1442,11 @@ "grantAccessButton": "Conceder acesso", "requestSuccessMicrophone": "Acesso ao microfone solicitado", "requestSuccessCamera": "Acesso à câmera solicitado", - "requestFailed": "Falha ao solicitar permissão" + "requestFailed": "Falha ao solicitar permissão", + "stillNotGrantedMicrophone": "O acesso ao microfone ainda não foi concedido. Pode ser necessário ativá-lo manualmente em Ajustes do Sistema → Privacidade e Segurança → Microfone.", + "stillNotGrantedCamera": "O acesso à câmera ainda não foi concedido. Pode ser necessário ativá-lo manualmente em Ajustes do Sistema → Privacidade e Segurança → Câmera.", + "grantedToastMicrophone": "Acesso ao microfone concedido", + "grantedToastCamera": "Acesso à câmera concedido" }, "traffic": { "title": "Detalhes do tráfego", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 67e6af7..9fb8447 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1442,7 +1442,11 @@ "grantAccessButton": "Предоставить доступ", "requestSuccessMicrophone": "Запрошен доступ к микрофону", "requestSuccessCamera": "Запрошен доступ к камере", - "requestFailed": "Не удалось запросить разрешение" + "requestFailed": "Не удалось запросить разрешение", + "stillNotGrantedMicrophone": "Доступ к микрофону всё ещё не предоставлен. Возможно, потребуется включить его вручную в Системных настройках → Конфиденциальность и безопасность → Микрофон.", + "stillNotGrantedCamera": "Доступ к камере всё ещё не предоставлен. Возможно, потребуется включить его вручную в Системных настройках → Конфиденциальность и безопасность → Камера.", + "grantedToastMicrophone": "Доступ к микрофону предоставлен", + "grantedToastCamera": "Доступ к камере предоставлен" }, "traffic": { "title": "Подробности трафика", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 60658eb..f4972d6 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1442,7 +1442,11 @@ "grantAccessButton": "授予访问", "requestSuccessMicrophone": "已请求麦克风访问", "requestSuccessCamera": "已请求摄像头访问", - "requestFailed": "请求权限失败" + "requestFailed": "请求权限失败", + "stillNotGrantedMicrophone": "麦克风访问权限仍未授予。您可能需要在系统设置 → 隐私与安全 → 麦克风中手动启用。", + "stillNotGrantedCamera": "摄像头访问权限仍未授予。您可能需要在系统设置 → 隐私与安全 → 摄像头中手动启用。", + "grantedToastMicrophone": "已授予麦克风访问权限", + "grantedToastCamera": "已授予摄像头访问权限" }, "traffic": { "title": "流量详情", diff --git a/src/lib/themes.ts b/src/lib/themes.ts index 1d60fa0..aaa1772 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -197,38 +197,45 @@ export const THEMES: Theme[] = [ { id: "ayu-light", name: "Ayu Light", + // Source: ayu-theme/ayu-colors light.yaml. Primary uses the iconic + // Ayu orange instead of blue — that's the colour the theme is known for. colors: { - "--background": "#fafafa", - "--foreground": "#5c6773", - "--card": "#ffffff", - "--card-foreground": "#5c6773", + "--background": "#f8f9fa", + "--foreground": "#5c6166", + "--card": "#fcfcfc", + "--card-foreground": "#5c6166", "--popover": "#ffffff", - "--popover-foreground": "#5c6773", - "--primary": "#399ee6", - "--primary-foreground": "#fafafa", - "--secondary": "#fa8d3e", - "--secondary-foreground": "#fafafa", - "--muted": "#f0f0f0", - "--muted-foreground": "#828c99", + "--popover-foreground": "#5c6166", + "--primary": "#f29718", + "--primary-foreground": "#ffffff", + "--secondary": "#399ee6", + "--secondary-foreground": "#ffffff", + "--muted": "#ebeef0", + "--muted-foreground": "#828e9f", "--accent": "#a37acc", - "--accent-foreground": "#fafafa", - "--destructive": "#f07178", - "--destructive-foreground": "#fafafa", + "--accent-foreground": "#ffffff", + "--destructive": "#e65050", + "--destructive-foreground": "#ffffff", "--success": "#86b300", - "--success-foreground": "#fafafa", - "--warning": "#fa8d3e", - "--warning-foreground": "#fafafa", - "--border": "#e7eaed", - "--chart-1": "#399ee6", + "--success-foreground": "#ffffff", + "--warning": "#fa8532", + "--warning-foreground": "#ffffff", + "--border": "#c8d0d6", + "--chart-1": "#f29718", "--chart-2": "#86b300", "--chart-3": "#a37acc", - "--chart-4": "#fa8d3e", - "--chart-5": "#f07178", + "--chart-4": "#399ee6", + "--chart-5": "#4cbf99", }, }, { id: "catppuccin-latte", name: "Catppuccin Latte", + // Source: github.com/catppuccin/palette/blob/main/palette.json + // Primary uses mauve (purple) — the colour Catppuccin is most known + // for — instead of blue, to differentiate from the many blue themes. + // Frappé and Macchiato variants intentionally omitted; they're tonal + // mid-points between Latte and Mocha and added little variety. colors: { "--background": "#eff1f5", "--foreground": "#4c4f69", @@ -236,13 +243,13 @@ export const THEMES: Theme[] = [ "--card-foreground": "#4c4f69", "--popover": "#ccd0da", "--popover-foreground": "#4c4f69", - "--primary": "#1e66f5", + "--primary": "#8839ef", "--primary-foreground": "#eff1f5", - "--secondary": "#04a5e5", + "--secondary": "#1e66f5", "--secondary-foreground": "#eff1f5", "--muted": "#bcc0cc", - "--muted-foreground": "#5c5f77", - "--accent": "#8839ef", + "--muted-foreground": "#6c6f85", + "--accent": "#ea76cb", "--accent-foreground": "#eff1f5", "--destructive": "#d20f39", "--destructive-foreground": "#eff1f5", @@ -251,80 +258,18 @@ export const THEMES: Theme[] = [ "--warning": "#df8e1d", "--warning-foreground": "#eff1f5", "--border": "#9ca0b0", - "--chart-1": "#1e66f5", + "--chart-1": "#8839ef", "--chart-2": "#40a02b", - "--chart-3": "#8839ef", + "--chart-3": "#ea76cb", "--chart-4": "#04a5e5", - "--chart-5": "#df8e1d", - }, - }, - { - id: "catppuccin-frappe", - name: "Catppuccin Frappe", - colors: { - "--background": "#303446", - "--foreground": "#c6d0f5", - "--card": "#414559", - "--card-foreground": "#c6d0f5", - "--popover": "#414559", - "--popover-foreground": "#c6d0f5", - "--primary": "#8caaee", - "--primary-foreground": "#303446", - "--secondary": "#99d1db", - "--secondary-foreground": "#303446", - "--muted": "#51576d", - "--muted-foreground": "#b5bfe2", - "--accent": "#ca9ee6", - "--accent-foreground": "#303446", - "--destructive": "#e78284", - "--destructive-foreground": "#303446", - "--success": "#a6d189", - "--success-foreground": "#303446", - "--warning": "#e5c890", - "--warning-foreground": "#303446", - "--border": "#737994", - "--chart-1": "#8caaee", - "--chart-2": "#a6d189", - "--chart-3": "#ca9ee6", - "--chart-4": "#99d1db", - "--chart-5": "#e5c890", - }, - }, - { - id: "catppuccin-macchiato", - name: "Catppuccin Macchiato", - colors: { - "--background": "#24273a", - "--foreground": "#cad3f5", - "--card": "#363a4f", - "--card-foreground": "#cad3f5", - "--popover": "#363a4f", - "--popover-foreground": "#cad3f5", - "--primary": "#8aadf4", - "--primary-foreground": "#24273a", - "--secondary": "#91d7e3", - "--secondary-foreground": "#24273a", - "--muted": "#494d64", - "--muted-foreground": "#b8c0e0", - "--accent": "#c6a0f6", - "--accent-foreground": "#24273a", - "--destructive": "#ed8796", - "--destructive-foreground": "#24273a", - "--success": "#a6da95", - "--success-foreground": "#24273a", - "--warning": "#eed49f", - "--warning-foreground": "#24273a", - "--border": "#6e738d", - "--chart-1": "#8aadf4", - "--chart-2": "#a6da95", - "--chart-3": "#c6a0f6", - "--chart-4": "#91d7e3", - "--chart-5": "#eed49f", + "--chart-5": "#fe640b", }, }, { id: "catppuccin-mocha", name: "Catppuccin Mocha", + // Source: github.com/catppuccin/palette/blob/main/palette.json + // Primary uses mauve (purple) — Catppuccin's signature colour. colors: { "--background": "#1e1e2e", "--foreground": "#cdd6f4", @@ -332,13 +277,13 @@ export const THEMES: Theme[] = [ "--card-foreground": "#cdd6f4", "--popover": "#313244", "--popover-foreground": "#cdd6f4", - "--primary": "#89b4fa", + "--primary": "#cba6f7", "--primary-foreground": "#1e1e2e", - "--secondary": "#89dceb", + "--secondary": "#89b4fa", "--secondary-foreground": "#1e1e2e", "--muted": "#45475a", - "--muted-foreground": "#bac2de", - "--accent": "#cba6f7", + "--muted-foreground": "#a6adc8", + "--accent": "#f5c2e7", "--accent-foreground": "#1e1e2e", "--destructive": "#f38ba8", "--destructive-foreground": "#1e1e2e", @@ -347,11 +292,381 @@ export const THEMES: Theme[] = [ "--warning": "#f9e2af", "--warning-foreground": "#1e1e2e", "--border": "#585b70", - "--chart-1": "#89b4fa", + "--chart-1": "#cba6f7", "--chart-2": "#a6e3a1", - "--chart-3": "#cba6f7", + "--chart-3": "#f5c2e7", "--chart-4": "#89dceb", - "--chart-5": "#f9e2af", + "--chart-5": "#fab387", + }, + }, + { + id: "nord", + name: "Nord", + // Source: nordtheme.com/docs/colors-and-palettes (Polar Night / Snow Storm / Frost / Aurora) + colors: { + "--background": "#2e3440", + "--foreground": "#d8dee9", + "--card": "#3b4252", + "--card-foreground": "#d8dee9", + "--popover": "#3b4252", + "--popover-foreground": "#d8dee9", + "--primary": "#81a1c1", + "--primary-foreground": "#2e3440", + "--secondary": "#88c0d0", + "--secondary-foreground": "#2e3440", + "--muted": "#434c5e", + "--muted-foreground": "#d8dee9", + "--accent": "#b48ead", + "--accent-foreground": "#2e3440", + "--destructive": "#bf616a", + "--destructive-foreground": "#eceff4", + "--success": "#a3be8c", + "--success-foreground": "#2e3440", + "--warning": "#ebcb8b", + "--warning-foreground": "#2e3440", + "--border": "#4c566a", + "--chart-1": "#81a1c1", + "--chart-2": "#a3be8c", + "--chart-3": "#b48ead", + "--chart-4": "#88c0d0", + "--chart-5": "#d08770", + }, + }, + { + id: "gruvbox-dark", + name: "Gruvbox Dark", + // Source: github.com/morhetz/gruvbox medium-contrast dark palette. + // Primary uses the iconic Gruvbox orange instead of blue. + colors: { + "--background": "#282828", + "--foreground": "#ebdbb2", + "--card": "#3c3836", + "--card-foreground": "#ebdbb2", + "--popover": "#3c3836", + "--popover-foreground": "#ebdbb2", + "--primary": "#fe8019", + "--primary-foreground": "#282828", + "--secondary": "#83a598", + "--secondary-foreground": "#282828", + "--muted": "#504945", + "--muted-foreground": "#a89984", + "--accent": "#d3869b", + "--accent-foreground": "#282828", + "--destructive": "#fb4934", + "--destructive-foreground": "#282828", + "--success": "#b8bb26", + "--success-foreground": "#282828", + "--warning": "#fabd2f", + "--warning-foreground": "#282828", + "--border": "#665c54", + "--chart-1": "#fe8019", + "--chart-2": "#b8bb26", + "--chart-3": "#d3869b", + "--chart-4": "#83a598", + "--chart-5": "#8ec07c", + }, + }, + { + id: "gruvbox-light", + name: "Gruvbox Light", + // Source: github.com/morhetz/gruvbox medium-contrast light palette. + // Primary uses the iconic Gruvbox orange instead of blue. + colors: { + "--background": "#fbf1c7", + "--foreground": "#3c3836", + "--card": "#ebdbb2", + "--card-foreground": "#3c3836", + "--popover": "#ebdbb2", + "--popover-foreground": "#3c3836", + "--primary": "#af3a03", + "--primary-foreground": "#fbf1c7", + "--secondary": "#076678", + "--secondary-foreground": "#fbf1c7", + "--muted": "#d5c4a1", + "--muted-foreground": "#7c6f64", + "--accent": "#8f3f71", + "--accent-foreground": "#fbf1c7", + "--destructive": "#9d0006", + "--destructive-foreground": "#fbf1c7", + "--success": "#79740e", + "--success-foreground": "#fbf1c7", + "--warning": "#b57614", + "--warning-foreground": "#fbf1c7", + "--border": "#a89984", + "--chart-1": "#af3a03", + "--chart-2": "#79740e", + "--chart-3": "#8f3f71", + "--chart-4": "#076678", + "--chart-5": "#427b58", + }, + }, + { + id: "solarized-dark", + name: "Solarized Dark", + // Source: ethanschoonover.com/solarized — base03 / base02 / base01 / base00 / base0 / base1 + colors: { + "--background": "#002b36", + "--foreground": "#839496", + "--card": "#073642", + "--card-foreground": "#839496", + "--popover": "#073642", + "--popover-foreground": "#839496", + "--primary": "#268bd2", + "--primary-foreground": "#002b36", + "--secondary": "#2aa198", + "--secondary-foreground": "#002b36", + "--muted": "#073642", + "--muted-foreground": "#93a1a1", + "--accent": "#6c71c4", + "--accent-foreground": "#fdf6e3", + "--destructive": "#dc322f", + "--destructive-foreground": "#fdf6e3", + "--success": "#859900", + "--success-foreground": "#002b36", + "--warning": "#b58900", + "--warning-foreground": "#002b36", + "--border": "#586e75", + "--chart-1": "#268bd2", + "--chart-2": "#859900", + "--chart-3": "#6c71c4", + "--chart-4": "#2aa198", + "--chart-5": "#cb4b16", + }, + }, + { + id: "solarized-light", + name: "Solarized Light", + // Source: ethanschoonover.com/solarized — same accents, inverted base scale + colors: { + "--background": "#fdf6e3", + "--foreground": "#657b83", + "--card": "#eee8d5", + "--card-foreground": "#657b83", + "--popover": "#eee8d5", + "--popover-foreground": "#657b83", + "--primary": "#268bd2", + "--primary-foreground": "#fdf6e3", + "--secondary": "#2aa198", + "--secondary-foreground": "#fdf6e3", + "--muted": "#eee8d5", + "--muted-foreground": "#93a1a1", + "--accent": "#6c71c4", + "--accent-foreground": "#fdf6e3", + "--destructive": "#dc322f", + "--destructive-foreground": "#fdf6e3", + "--success": "#859900", + "--success-foreground": "#fdf6e3", + "--warning": "#b58900", + "--warning-foreground": "#fdf6e3", + "--border": "#cdc7b3", + "--chart-1": "#268bd2", + "--chart-2": "#859900", + "--chart-3": "#6c71c4", + "--chart-4": "#2aa198", + "--chart-5": "#cb4b16", + }, + }, + { + id: "one-dark", + name: "One Dark", + // Source: github.com/atom/atom one-dark-syntax/styles/colors.less (mono-1, hue-1..6) + colors: { + "--background": "#282c34", + "--foreground": "#abb2bf", + "--card": "#21252b", + "--card-foreground": "#abb2bf", + "--popover": "#21252b", + "--popover-foreground": "#abb2bf", + "--primary": "#61afef", + "--primary-foreground": "#282c34", + "--secondary": "#56b6c2", + "--secondary-foreground": "#282c34", + "--muted": "#3e4451", + "--muted-foreground": "#7d8590", + "--accent": "#c678dd", + "--accent-foreground": "#282c34", + "--destructive": "#e06c75", + "--destructive-foreground": "#282c34", + "--success": "#98c379", + "--success-foreground": "#282c34", + "--warning": "#e5c07b", + "--warning-foreground": "#282c34", + "--border": "#3e4451", + "--chart-1": "#61afef", + "--chart-2": "#98c379", + "--chart-3": "#c678dd", + "--chart-4": "#56b6c2", + "--chart-5": "#d19a66", + }, + }, + { + id: "monokai-pro", + name: "Monokai Pro", + // Source: classic Monokai filter (monokai-pro.nvim palette/classic.lua). + // Primary uses Monokai's signature green instead of cyan. + colors: { + "--background": "#272822", + "--foreground": "#fdfff1", + "--card": "#1d1e19", + "--card-foreground": "#fdfff1", + "--popover": "#1d1e19", + "--popover-foreground": "#fdfff1", + "--primary": "#a6e22e", + "--primary-foreground": "#272822", + "--secondary": "#66d9ef", + "--secondary-foreground": "#272822", + "--muted": "#3b3c35", + "--muted-foreground": "#919288", + "--accent": "#ae81ff", + "--accent-foreground": "#272822", + "--destructive": "#f92672", + "--destructive-foreground": "#fdfff1", + "--success": "#a6e22e", + "--success-foreground": "#272822", + "--warning": "#e6db74", + "--warning-foreground": "#272822", + "--border": "#57584f", + "--chart-1": "#a6e22e", + "--chart-2": "#66d9ef", + "--chart-3": "#ae81ff", + "--chart-4": "#e6db74", + "--chart-5": "#fd971f", + }, + }, + { + id: "rose-pine", + name: "Rosé Pine", + // Source: github.com/rose-pine/palette/blob/main/palette.json. + // Primary uses iris (purple) — the iconic Rosé Pine accent — and + // success uses pine. Destructive stays love (pink), which is correct + // for the palette's red role. + colors: { + "--background": "#191724", + "--foreground": "#e0def4", + "--card": "#1f1d2e", + "--card-foreground": "#e0def4", + "--popover": "#1f1d2e", + "--popover-foreground": "#e0def4", + "--primary": "#c4a7e7", + "--primary-foreground": "#191724", + "--secondary": "#9ccfd8", + "--secondary-foreground": "#191724", + "--muted": "#26233a", + "--muted-foreground": "#908caa", + "--accent": "#ebbcba", + "--accent-foreground": "#191724", + "--destructive": "#eb6f92", + "--destructive-foreground": "#191724", + "--success": "#31748f", + "--success-foreground": "#e0def4", + "--warning": "#f6c177", + "--warning-foreground": "#191724", + "--border": "#403d52", + "--chart-1": "#c4a7e7", + "--chart-2": "#9ccfd8", + "--chart-3": "#ebbcba", + "--chart-4": "#eb6f92", + "--chart-5": "#f6c177", + }, + }, + { + id: "rose-pine-dawn", + name: "Rosé Pine Dawn", + // Source: github.com/rose-pine/palette/blob/main/palette.json (dawn variant). + // Primary uses iris (purple) for parity with the dark variant. + colors: { + "--background": "#faf4ed", + "--foreground": "#575279", + "--card": "#fffaf3", + "--card-foreground": "#575279", + "--popover": "#fffaf3", + "--popover-foreground": "#575279", + "--primary": "#907aa9", + "--primary-foreground": "#faf4ed", + "--secondary": "#56949f", + "--secondary-foreground": "#faf4ed", + "--muted": "#f2e9e1", + "--muted-foreground": "#797593", + "--accent": "#d7827e", + "--accent-foreground": "#faf4ed", + "--destructive": "#b4637a", + "--destructive-foreground": "#faf4ed", + "--success": "#286983", + "--success-foreground": "#faf4ed", + "--warning": "#ea9d34", + "--warning-foreground": "#faf4ed", + "--border": "#cecacd", + "--chart-1": "#907aa9", + "--chart-2": "#56949f", + "--chart-3": "#d7827e", + "--chart-4": "#b4637a", + "--chart-5": "#ea9d34", + }, + }, + { + id: "github-dark", + name: "GitHub Dark", + // Source: github.com/primer/primitives base color tokens (dark default) + colors: { + "--background": "#0d1117", + "--foreground": "#f0f6fc", + "--card": "#151b23", + "--card-foreground": "#f0f6fc", + "--popover": "#151b23", + "--popover-foreground": "#f0f6fc", + "--primary": "#1f6feb", + "--primary-foreground": "#f0f6fc", + "--secondary": "#58a6ff", + "--secondary-foreground": "#0d1117", + "--muted": "#212830", + "--muted-foreground": "#9198a1", + "--accent": "#8957e5", + "--accent-foreground": "#f0f6fc", + "--destructive": "#da3633", + "--destructive-foreground": "#f0f6fc", + "--success": "#238636", + "--success-foreground": "#f0f6fc", + "--warning": "#d29922", + "--warning-foreground": "#0d1117", + "--border": "#3d444d", + "--chart-1": "#1f6feb", + "--chart-2": "#238636", + "--chart-3": "#8957e5", + "--chart-4": "#58a6ff", + "--chart-5": "#db6d28", + }, + }, + { + id: "github-light", + name: "GitHub Light", + // Source: github.com/primer/primitives base color tokens (light default) + colors: { + "--background": "#ffffff", + "--foreground": "#25292e", + "--card": "#f6f8fa", + "--card-foreground": "#25292e", + "--popover": "#f6f8fa", + "--popover-foreground": "#25292e", + "--primary": "#0969da", + "--primary-foreground": "#ffffff", + "--secondary": "#54aeff", + "--secondary-foreground": "#ffffff", + "--muted": "#eff2f5", + "--muted-foreground": "#59636e", + "--accent": "#8250df", + "--accent-foreground": "#ffffff", + "--destructive": "#cf222e", + "--destructive-foreground": "#ffffff", + "--success": "#1a7f37", + "--success-foreground": "#ffffff", + "--warning": "#bf8700", + "--warning-foreground": "#ffffff", + "--border": "#d1d9e0", + "--chart-1": "#0969da", + "--chart-2": "#1a7f37", + "--chart-3": "#8250df", + "--chart-4": "#54aeff", + "--chart-5": "#bc4c00", }, }, ];