mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-12 09:47:51 +02:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5356d59d72 | |||
| 34450ad06b | |||
| 904dda2bad | |||
| 39b13ead5b | |||
| 62c84b52fc | |||
| 828c3bb984 | |||
| ffe35c1672 | |||
| 4a4cf81255 | |||
| 77be8cadaf | |||
| 3207e4fbd3 | |||
| c18e9625fd | |||
| d06ddccd78 | |||
| 04297fc27d | |||
| 1d404833ad | |||
| f61a3905fa | |||
| 79d8b83b57 | |||
| e700b47b4c | |||
| 57167b979f | |||
| 571bfcb213 | |||
| 6721444822 | |||
| ef1dc3407f | |||
| 1162f1e9f3 |
@@ -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_TEAM_ID=
|
||||||
APPLE_ID=
|
APPLE_ID=
|
||||||
APPLE_PASSWORD=
|
APPLE_PASSWORD=
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
use flake
|
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
|
||||||
|
|||||||
@@ -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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ permissions:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
id-token: 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:
|
jobs:
|
||||||
analyze-issue:
|
analyze-issue:
|
||||||
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'issues'
|
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'issues'
|
||||||
@@ -40,41 +45,150 @@ jobs:
|
|||||||
echo "is_first_time=false" >> $GITHUB_OUTPUT
|
echo "is_first_time=false" >> $GITHUB_OUTPUT
|
||||||
fi
|
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:
|
env:
|
||||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||||
run: |
|
run: |
|
||||||
# Read project guidelines (contains repo structure)
|
|
||||||
cp CLAUDE.md /tmp/repo-context.txt
|
cp CLAUDE.md /tmp/repo-context.txt
|
||||||
|
|
||||||
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
|
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
|
||||||
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.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" \) \
|
find . -type f \( -name "*.rs" -o -name "*.ts" -o -name "*.tsx" \) \
|
||||||
! -path "*/node_modules/*" ! -path "*/target/*" ! -path "*/.next/*" ! -path "*/dist/*" \
|
! -path "*/node_modules/*" ! -path "*/target/*" ! -path "*/.next/*" ! -path "*/dist/*" \
|
||||||
! -path "*/.git/*" ! -path "*/gen/*" ! -path "*/data/*" \
|
! -path "*/.git/*" ! -path "*/gen/*" ! -path "*/data/*" \
|
||||||
| sed 's|^\./||' | sort > /tmp/all-source-files.txt
|
| 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:
|
env:
|
||||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||||
run: |
|
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 \
|
PAYLOAD=$(jq -n \
|
||||||
|
--arg model "$TRIAGE_MODEL" \
|
||||||
--rawfile title /tmp/issue-title.txt \
|
--rawfile title /tmp/issue-title.txt \
|
||||||
--rawfile body /tmp/issue-body.txt \
|
--rawfile body /tmp/issue-body.txt \
|
||||||
|
--rawfile fields /tmp/issue-fields.json \
|
||||||
--rawfile files /tmp/all-source-files.txt \
|
--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: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "system",
|
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",
|
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" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$PAYLOAD")
|
-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)
|
# Strip ```json fences if the model couldn't help itself.
|
||||||
echo "" > /tmp/file-contents.txt
|
sed -E 's/^```(json)?$//; s/```$//' /tmp/triage-raw.txt > /tmp/triage.json
|
||||||
while IFS= read -r filepath; do
|
|
||||||
|
# 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)
|
filepath=$(echo "$filepath" | xargs)
|
||||||
[ -z "$filepath" ] && continue
|
[ -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
|
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
|
||||||
echo "=== $filepath ===" >> /tmp/file-contents.txt
|
echo "=== $filepath ===" >> /tmp/file-context.txt
|
||||||
cat "$filepath" >> /tmp/file-contents.txt
|
cat "$filepath" >> /tmp/file-context.txt
|
||||||
echo "" >> /tmp/file-contents.txt
|
echo "" >> /tmp/file-context.txt
|
||||||
fi
|
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
|
- name: Stage 2 — Compose response
|
||||||
head -c 100000 /tmp/file-contents.txt > /tmp/file-context.txt
|
|
||||||
|
|
||||||
- name: Analyze issue with AI
|
|
||||||
env:
|
env:
|
||||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
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 }}
|
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
|
||||||
IS_FIRST_TIME: ${{ steps.check-first-time.outputs.is_first_time }}
|
IS_FIRST_TIME: ${{ steps.check-first-time.outputs.is_first_time }}
|
||||||
run: |
|
run: |
|
||||||
GREETING=""
|
GREETING=""
|
||||||
if [ "$IS_FIRST_TIME" = "true" ]; then
|
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
|
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' "$GREETING" > /tmp/greeting.txt
|
||||||
|
printf '%s' "$ISSUE_AUTHOR" > /tmp/issue-author.txt
|
||||||
|
|
||||||
PAYLOAD=$(jq -n \
|
PAYLOAD=$(jq -n \
|
||||||
|
--arg model "$COMPOSER_MODEL" \
|
||||||
--rawfile title /tmp/issue-title.txt \
|
--rawfile title /tmp/issue-title.txt \
|
||||||
--rawfile body /tmp/issue-body.txt \
|
--rawfile body /tmp/issue-body.txt \
|
||||||
--rawfile author /tmp/issue-author.txt \
|
--rawfile author /tmp/issue-author.txt \
|
||||||
|
--rawfile fields /tmp/issue-fields.json \
|
||||||
|
--rawfile triage /tmp/triage.json \
|
||||||
--rawfile greeting /tmp/greeting.txt \
|
--rawfile greeting /tmp/greeting.txt \
|
||||||
--rawfile repo_context /tmp/repo-context.txt \
|
--rawfile files /tmp/file-context.txt \
|
||||||
--rawfile context /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: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "system",
|
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 <container>` 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",
|
role: "user",
|
||||||
content: (
|
content: ((if ($greeting | length) > 0 then $greeting + "\n\n" else "" end) +
|
||||||
(if ($greeting | length) > 0 then $greeting + "\n\n" else "" end) +
|
"Title: " + $title +
|
||||||
"Analyze this issue:\n\nTitle: " + $title +
|
|
||||||
"\nAuthor: " + $author +
|
"\nAuthor: " + $author +
|
||||||
"\n\nBody:\n" + $body +
|
"\n\n## Triage result\n" + $triage +
|
||||||
"\n\nRelevant source files:\n" + $context
|
"\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
|
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/ai-comment.txt
|
||||||
|
|
||||||
if [ ! -s /tmp/ai-comment.txt ]; then
|
if [ ! -s /tmp/ai-comment.txt ]; then
|
||||||
echo "::error::AI response was empty"
|
echo "::error::Composer returned empty response"
|
||||||
echo "Raw response:"
|
echo "Raw response:"
|
||||||
echo "$RESPONSE"
|
echo "$RESPONSE"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||||
run: |
|
run: |
|
||||||
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
|
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:
|
analyze-pr:
|
||||||
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]'
|
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -204,26 +361,20 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
run: |
|
run: |
|
||||||
# Get changed files list
|
|
||||||
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" \
|
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" \
|
||||||
--jq '.[] | "- \(.filename) (\(.status)) +\(.additions)/-\(.deletions)"' \
|
--jq '.[] | "- \(.filename) (\(.status)) +\(.additions)/-\(.deletions)"' \
|
||||||
> /tmp/pr-files.txt
|
> /tmp/pr-files.txt
|
||||||
|
|
||||||
# Get the actual diff
|
|
||||||
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \
|
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \
|
||||||
--header "Accept: application/vnd.github.diff" \
|
--header "Accept: application/vnd.github.diff" \
|
||||||
> /tmp/pr-diff-full.txt 2>/dev/null || true
|
> /tmp/pr-diff-full.txt 2>/dev/null || true
|
||||||
head -c 20000 /tmp/pr-diff-full.txt > /tmp/pr-diff.txt
|
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
|
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
|
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
|
cp CLAUDE.md /tmp/repo-context.txt
|
||||||
|
|
||||||
# Read full contents of all changed files (skip binary)
|
: > /tmp/related-file-contents.txt
|
||||||
echo "" > /tmp/related-file-contents.txt
|
|
||||||
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' | while IFS= read -r filepath; do
|
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
|
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
|
||||||
echo "=== $filepath (full file) ===" >> /tmp/related-file-contents.txt
|
echo "=== $filepath (full file) ===" >> /tmp/related-file-contents.txt
|
||||||
@@ -258,6 +409,7 @@ jobs:
|
|||||||
printf '%s' "$GREETING" > /tmp/greeting.txt
|
printf '%s' "$GREETING" > /tmp/greeting.txt
|
||||||
|
|
||||||
PAYLOAD=$(jq -n \
|
PAYLOAD=$(jq -n \
|
||||||
|
--arg model "$COMPOSER_MODEL" \
|
||||||
--rawfile title /tmp/pr-title.txt \
|
--rawfile title /tmp/pr-title.txt \
|
||||||
--rawfile body /tmp/pr-body.txt \
|
--rawfile body /tmp/pr-body.txt \
|
||||||
--rawfile author /tmp/pr-author.txt \
|
--rawfile author /tmp/pr-author.txt \
|
||||||
@@ -270,7 +422,7 @@ jobs:
|
|||||||
--rawfile contributing /tmp/contributing.txt \
|
--rawfile contributing /tmp/contributing.txt \
|
||||||
--rawfile file_context /tmp/pr-file-context.txt \
|
--rawfile file_context /tmp/pr-file-context.txt \
|
||||||
'{
|
'{
|
||||||
model: "anthropic/claude-opus-4.6",
|
model: $model,
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "system",
|
role: "system",
|
||||||
@@ -327,7 +479,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@da6683fedcbb57a36c4ba54ba5ad00dd8bc2da65 #v1.14.24
|
uses: anomalyco/opencode/github@557734bd130a68188454bc691e153f9f3731830e #v1.14.31
|
||||||
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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
|
||||||
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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
|||||||
@@ -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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
|||||||
@@ -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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
|||||||
@@ -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@cf5f1c29a8ac336af8568821ec41919923b05a83 #v1.45.1
|
uses: crate-ci/typos@bbaefadf97b0ec5fdc942684b647f1a6ab250274 #v1.46.0
|
||||||
|
|||||||
@@ -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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
|
||||||
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@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a #v6.0.4
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ donutbrowser/
|
|||||||
- 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 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.
|
||||||
- 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
## Singletons
|
## Singletons
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,55 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
|
||||||
|
## v0.22.6 (2026-05-03)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- vpn manipulation via the api
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- don't block ui on clade check
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- update CHANGELOG.md and README.md for v0.22.5 [skip ci] (#327)
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- chore: version bump
|
||||||
|
- chore: rand bump
|
||||||
|
- chore: pnpm bump
|
||||||
|
- ci(deps): bump the github-actions group with 3 updates (#330)
|
||||||
|
- chore: update flake.nix for v0.22.5 [skip ci] (#328)
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- deps(rust)(deps): bump the rust-dependencies group (#331)
|
||||||
|
|
||||||
|
|
||||||
|
## v0.22.5 (2026-04-29)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- declare libxdo as runtime dependency
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- chore: version bump
|
||||||
|
- chore: copy
|
||||||
|
- chore: update flake.nix for v0.22.4 [skip ci] (#324)
|
||||||
|
|
||||||
|
|
||||||
|
## v0.22.4 (2026-04-28)
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- chore: version bump
|
||||||
|
- chore: i18n
|
||||||
|
- chore: update flake.nix for v0.22.3 [skip ci] (#321)
|
||||||
|
|
||||||
|
|
||||||
## v0.22.3 (2026-04-27)
|
## v0.22.3 (2026-04-27)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
|
|
||||||
| | Apple Silicon | Intel |
|
| | Apple Silicon | Intel |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.3/Donut_0.22.3_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.3/Donut_0.22.3_x64.dmg) |
|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_x64.dmg) |
|
||||||
|
|
||||||
Or install via Homebrew:
|
Or install via Homebrew:
|
||||||
|
|
||||||
@@ -61,15 +61,15 @@ brew install --cask donut
|
|||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.3/Donut_0.22.3_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.3/Donut_0.22.3_x64-portable.zip)
|
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_x64-portable.zip)
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
| Format | x86_64 | ARM64 |
|
| Format | x86_64 | ARM64 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.3/Donut_0.22.3_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.3/Donut_0.22.3_arm64.deb) |
|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_arm64.deb) |
|
||||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.3/Donut-0.22.3-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.3/Donut-0.22.3-1.aarch64.rpm) |
|
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut-0.22.6-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut-0.22.6-1.aarch64.rpm) |
|
||||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.3/Donut_0.22.3_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.3/Donut_0.22.3_aarch64.AppImage) |
|
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_aarch64.AppImage) |
|
||||||
<!-- install-links-end -->
|
<!-- install-links-end -->
|
||||||
|
|
||||||
Or install via package manager:
|
Or install via package manager:
|
||||||
@@ -160,6 +160,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|||||||
<br />
|
<br />
|
||||||
<sub><b>Jory Severijnse</b></sub>
|
<sub><b>Jory Severijnse</b></sub>
|
||||||
</a>
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/ThiagoMafra-Integrare">
|
||||||
|
<img src="https://avatars.githubusercontent.com/u/222241596?v=4" width="100;" alt="ThiagoMafra-Integrare"/>
|
||||||
|
<br />
|
||||||
|
<sub><b>Thiago Mafra</b></sub>
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -94,17 +94,17 @@
|
|||||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||||
);
|
);
|
||||||
releaseVersion = "0.22.3";
|
releaseVersion = "0.22.6";
|
||||||
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.22.3/Donut_0.22.3_amd64.AppImage";
|
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_amd64.AppImage";
|
||||||
hash = "sha256-x2JDIdy6A0u4chw+bHedKsBaz2Ph8tSUR/TR9C3Ae5I=";
|
hash = "sha256-sbYM8YKfQznGDl7kCJFDH2Ak+q//vYuHM6loXHckOAs=";
|
||||||
}
|
}
|
||||||
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.22.3/Donut_0.22.3_aarch64.AppImage";
|
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.6/Donut_0.22.6_aarch64.AppImage";
|
||||||
hash = "sha256-Oee7kwx6vhv+ZifqxOcGoZ1K00tYY1j7JAHU8LNMt/w=";
|
hash = "sha256-piMZR+ZxOyaxz6lom6aRZDyuU5fsu3kJFbOSsS5YuKI=";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
null;
|
null;
|
||||||
|
|||||||
+5
-5
@@ -2,7 +2,7 @@
|
|||||||
"name": "donutbrowser",
|
"name": "donutbrowser",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"version": "0.22.4",
|
"version": "0.22.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 12341",
|
"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: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:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
|
||||||
"lint:spell": "typos .",
|
"lint:spell": "typos .",
|
||||||
"tauri": "tauri",
|
"tauri": "node scripts/run-with-env.mjs tauri",
|
||||||
"shadcn:add": "pnpm dlx shadcn@latest add",
|
"shadcn:add": "pnpm dlx shadcn@latest add",
|
||||||
"prepare": "husky && husky install",
|
"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",
|
"format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tauri-apps/api": "~2.10.1",
|
"@tauri-apps/api": "~2.11.0",
|
||||||
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||||
"@tauri-apps/plugin-fs": "~2.5.0",
|
"@tauri-apps/plugin-fs": "~2.5.0",
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.10",
|
"@biomejs/biome": "2.4.10",
|
||||||
"@tailwindcss/postcss": "^4.2.2",
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
"@tauri-apps/cli": "~2.10.1",
|
"@tauri-apps/cli": "~2.11.0",
|
||||||
"@types/color": "^4.2.1",
|
"@types/color": "^4.2.1",
|
||||||
"@types/node": "^25.5.2",
|
"@types/node": "^25.5.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
"fast-xml-parser@<5.7.0": ">=5.7.2"
|
"fast-xml-parser@<5.7.0": ">=5.7.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.33.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
+60
-60
@@ -54,8 +54,8 @@ importers:
|
|||||||
specifier: ^8.21.3
|
specifier: ^8.21.3
|
||||||
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
'@tauri-apps/api':
|
'@tauri-apps/api':
|
||||||
specifier: ~2.10.1
|
specifier: ~2.11.0
|
||||||
version: 2.10.1
|
version: 2.11.0
|
||||||
'@tauri-apps/plugin-deep-link':
|
'@tauri-apps/plugin-deep-link':
|
||||||
specifier: ^2.4.7
|
specifier: ^2.4.7
|
||||||
version: 2.4.7
|
version: 2.4.7
|
||||||
@@ -139,8 +139,8 @@ importers:
|
|||||||
specifier: ^4.2.2
|
specifier: ^4.2.2
|
||||||
version: 4.2.2
|
version: 4.2.2
|
||||||
'@tauri-apps/cli':
|
'@tauri-apps/cli':
|
||||||
specifier: ~2.10.1
|
specifier: ~2.11.0
|
||||||
version: 2.10.1
|
version: 2.11.0
|
||||||
'@types/color':
|
'@types/color':
|
||||||
specifier: ^4.2.1
|
specifier: ^4.2.1
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
@@ -2678,82 +2678,82 @@ packages:
|
|||||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
'@tauri-apps/api@2.10.1':
|
'@tauri-apps/api@2.11.0':
|
||||||
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==}
|
||||||
|
|
||||||
'@tauri-apps/cli-darwin-arm64@2.10.1':
|
'@tauri-apps/cli-darwin-arm64@2.11.0':
|
||||||
resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==}
|
resolution: {integrity: sha512-UfMeDNlgIP252rm/KSTuu8yHatPua5TjtUEUf+jyIzVwBNcIl7Ywkdpfj+e5jVVg3EfCTp+4gwuL1dNpgF8clg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@tauri-apps/cli-darwin-x64@2.10.1':
|
'@tauri-apps/cli-darwin-x64@2.11.0':
|
||||||
resolution: {integrity: sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==}
|
resolution: {integrity: sha512-lY1+aPlgyMN7vgjtCdQ3+WODfZkebAcxnrCrO0HjqDpKSXieDkrJbimqeaoM4RwhTSrCLRHfVYiYrfE5E131tg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.10.1':
|
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.0':
|
||||||
resolution: {integrity: sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==}
|
resolution: {integrity: sha512-5uCP0AusgN3NrKC8EpkuJwjek1k8pEffBdugJSpXPey/QGbPEb8vZ542n/giJ2mZPjMSllDkdhG2QIDpBY4PpQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-arm64-gnu@2.10.1':
|
'@tauri-apps/cli-linux-arm64-gnu@2.11.0':
|
||||||
resolution: {integrity: sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==}
|
resolution: {integrity: sha512-loDPqtRHMSbIcrH2VBd4GgHoQlF7jJnrZj7MxA2lj1cixS/jEgMAPFqj83U6Wvjete4HfYplbE/gCpSFifA9jw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-arm64-musl@2.10.1':
|
'@tauri-apps/cli-linux-arm64-musl@2.11.0':
|
||||||
resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==}
|
resolution: {integrity: sha512-DtSE8ZBlB9H+L+eHkfZ3myt00EVEyAB3e41juEHoE2qT88fgVlJvyrwa9SZYc/xTwCS9TnmK+R84tpg+ZsAg7Q==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-riscv64-gnu@2.10.1':
|
'@tauri-apps/cli-linux-riscv64-gnu@2.11.0':
|
||||||
resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==}
|
resolution: {integrity: sha512-5QdgS4LD+kntClI1aj2JmwjW38LosNXxwCe8viIHEwqYIWuMPdNEIau6/cLogI38Yzx9DnfCPRfEWLyI+5li8Q==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-x64-gnu@2.10.1':
|
'@tauri-apps/cli-linux-x64-gnu@2.11.0':
|
||||||
resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==}
|
resolution: {integrity: sha512-5UynPXo3Zq9khjVdAbD+YogeLltdVUeOah2ioSIM3tu6H7wY9vMy6rgGJhv9r5R8ZXmk9GttMippdqYJWrnLnA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-x64-musl@2.10.1':
|
'@tauri-apps/cli-linux-x64-musl@2.11.0':
|
||||||
resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==}
|
resolution: {integrity: sha512-CNz7fHbApz1Zyhhq73jtGn9JqgNEV/lIWnTnUo6h6ujw+mHsTmkLszvJSM8W6JBaDjNpTTFr/RSNoVL5FMwcTg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@tauri-apps/cli-win32-arm64-msvc@2.10.1':
|
'@tauri-apps/cli-win32-arm64-msvc@2.11.0':
|
||||||
resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==}
|
resolution: {integrity: sha512-K+br+VXZ+Xx0n/9FdWohpW5Ugq+2FQUpJScqcPl1hTxXfh3fgjYgt4qA2NgrjlJo+zZPNrmUMl+NLvm0ufEqBQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@tauri-apps/cli-win32-ia32-msvc@2.10.1':
|
'@tauri-apps/cli-win32-ia32-msvc@2.11.0':
|
||||||
resolution: {integrity: sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==}
|
resolution: {integrity: sha512-OFV+s3MLZnd75zl0ZAFU5riMpGK4waUEA8ZDuijDsnkU0btz/gHhqh5jVlOn8thyvgdtT3Xyoxqo099MMifH3g==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@tauri-apps/cli-win32-x64-msvc@2.10.1':
|
'@tauri-apps/cli-win32-x64-msvc@2.11.0':
|
||||||
resolution: {integrity: sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==}
|
resolution: {integrity: sha512-AeDTWBd2cOZ6TX133BWsoo+LutG9o0JRcgjMsIfLE13ZugpgCMv/2dJbUiBGeRvbPOGin5A3aYmsArPVV6ZSHQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@tauri-apps/cli@2.10.1':
|
'@tauri-apps/cli@2.11.0':
|
||||||
resolution: {integrity: sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==}
|
resolution: {integrity: sha512-W5Wbuqsb2pHFPTj4TaRNKTj5rwXhDShPiLSY9T18y4ouSR/NNCptAEFxFsBtyNRgL6Vs1a/q9LzfqqYzEwC+Jw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@@ -8343,74 +8343,74 @@ snapshots:
|
|||||||
|
|
||||||
'@tanstack/table-core@8.21.3': {}
|
'@tanstack/table-core@8.21.3': {}
|
||||||
|
|
||||||
'@tauri-apps/api@2.10.1': {}
|
'@tauri-apps/api@2.11.0': {}
|
||||||
|
|
||||||
'@tauri-apps/cli-darwin-arm64@2.10.1':
|
'@tauri-apps/cli-darwin-arm64@2.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@tauri-apps/cli-darwin-x64@2.10.1':
|
'@tauri-apps/cli-darwin-x64@2.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.10.1':
|
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-arm64-gnu@2.10.1':
|
'@tauri-apps/cli-linux-arm64-gnu@2.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-arm64-musl@2.10.1':
|
'@tauri-apps/cli-linux-arm64-musl@2.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-riscv64-gnu@2.10.1':
|
'@tauri-apps/cli-linux-riscv64-gnu@2.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-x64-gnu@2.10.1':
|
'@tauri-apps/cli-linux-x64-gnu@2.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-x64-musl@2.10.1':
|
'@tauri-apps/cli-linux-x64-musl@2.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@tauri-apps/cli-win32-arm64-msvc@2.10.1':
|
'@tauri-apps/cli-win32-arm64-msvc@2.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@tauri-apps/cli-win32-ia32-msvc@2.10.1':
|
'@tauri-apps/cli-win32-ia32-msvc@2.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@tauri-apps/cli-win32-x64-msvc@2.10.1':
|
'@tauri-apps/cli-win32-x64-msvc@2.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@tauri-apps/cli@2.10.1':
|
'@tauri-apps/cli@2.11.0':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@tauri-apps/cli-darwin-arm64': 2.10.1
|
'@tauri-apps/cli-darwin-arm64': 2.11.0
|
||||||
'@tauri-apps/cli-darwin-x64': 2.10.1
|
'@tauri-apps/cli-darwin-x64': 2.11.0
|
||||||
'@tauri-apps/cli-linux-arm-gnueabihf': 2.10.1
|
'@tauri-apps/cli-linux-arm-gnueabihf': 2.11.0
|
||||||
'@tauri-apps/cli-linux-arm64-gnu': 2.10.1
|
'@tauri-apps/cli-linux-arm64-gnu': 2.11.0
|
||||||
'@tauri-apps/cli-linux-arm64-musl': 2.10.1
|
'@tauri-apps/cli-linux-arm64-musl': 2.11.0
|
||||||
'@tauri-apps/cli-linux-riscv64-gnu': 2.10.1
|
'@tauri-apps/cli-linux-riscv64-gnu': 2.11.0
|
||||||
'@tauri-apps/cli-linux-x64-gnu': 2.10.1
|
'@tauri-apps/cli-linux-x64-gnu': 2.11.0
|
||||||
'@tauri-apps/cli-linux-x64-musl': 2.10.1
|
'@tauri-apps/cli-linux-x64-musl': 2.11.0
|
||||||
'@tauri-apps/cli-win32-arm64-msvc': 2.10.1
|
'@tauri-apps/cli-win32-arm64-msvc': 2.11.0
|
||||||
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
|
'@tauri-apps/cli-win32-ia32-msvc': 2.11.0
|
||||||
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
|
'@tauri-apps/cli-win32-x64-msvc': 2.11.0
|
||||||
|
|
||||||
'@tauri-apps/plugin-deep-link@2.4.7':
|
'@tauri-apps/plugin-deep-link@2.4.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.10.1
|
'@tauri-apps/api': 2.11.0
|
||||||
|
|
||||||
'@tauri-apps/plugin-dialog@2.7.0':
|
'@tauri-apps/plugin-dialog@2.7.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.10.1
|
'@tauri-apps/api': 2.11.0
|
||||||
|
|
||||||
'@tauri-apps/plugin-fs@2.5.0':
|
'@tauri-apps/plugin-fs@2.5.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.10.1
|
'@tauri-apps/api': 2.11.0
|
||||||
|
|
||||||
'@tauri-apps/plugin-log@2.8.0':
|
'@tauri-apps/plugin-log@2.8.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.10.1
|
'@tauri-apps/api': 2.11.0
|
||||||
|
|
||||||
'@tauri-apps/plugin-opener@2.5.3':
|
'@tauri-apps/plugin-opener@2.5.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.10.1
|
'@tauri-apps/api': 2.11.0
|
||||||
|
|
||||||
'@tokenizer/inflate@0.4.1':
|
'@tokenizer/inflate@0.4.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -11037,7 +11037,7 @@ snapshots:
|
|||||||
|
|
||||||
tauri-plugin-macos-permissions-api@2.3.0:
|
tauri-plugin-macos-permissions-api@2.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.10.1
|
'@tauri-apps/api': 2.11.0
|
||||||
|
|
||||||
terser-webpack-plugin@5.4.0(webpack@5.105.4):
|
terser-webpack-plugin@5.4.0(webpack@5.105.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Executable
+58
@@ -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 <command> [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);
|
||||||
|
}
|
||||||
|
});
|
||||||
Generated
+142
-589
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "donutbrowser"
|
name = "donutbrowser"
|
||||||
version = "0.22.4"
|
version = "0.22.7"
|
||||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||||
authors = ["zhom@github"]
|
authors = ["zhom@github"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -102,7 +102,7 @@ serde_yaml = "0.9"
|
|||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
regex-lite = "0.1"
|
regex-lite = "0.1"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
maxminddb = "0.27"
|
maxminddb = "0.28"
|
||||||
quick-xml = { version = "0.39", features = ["serialize"] }
|
quick-xml = { version = "0.39", features = ["serialize"] }
|
||||||
|
|
||||||
# VPN support
|
# VPN support
|
||||||
@@ -110,7 +110,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)
|
# Daemon dependencies (tray icon)
|
||||||
tray-icon = "0.22"
|
tray-icon = "0.23"
|
||||||
tao = "0.35"
|
tao = "0.35"
|
||||||
image = "0.25"
|
image = "0.25"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ pub struct ApiProfile {
|
|||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
pub is_running: bool,
|
pub is_running: bool,
|
||||||
pub proxy_bypass_rules: Vec<String>,
|
pub proxy_bypass_rules: Vec<String>,
|
||||||
|
pub vpn_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
@@ -60,6 +61,7 @@ pub struct CreateProfileRequest {
|
|||||||
pub browser: String,
|
pub browser: String,
|
||||||
pub version: String,
|
pub version: String,
|
||||||
pub proxy_id: Option<String>,
|
pub proxy_id: Option<String>,
|
||||||
|
pub vpn_id: Option<String>,
|
||||||
pub launch_hook: Option<String>,
|
pub launch_hook: Option<String>,
|
||||||
pub release_type: Option<String>,
|
pub release_type: Option<String>,
|
||||||
#[schema(value_type = Object)]
|
#[schema(value_type = Object)]
|
||||||
@@ -76,6 +78,7 @@ pub struct UpdateProfileRequest {
|
|||||||
pub browser: Option<String>,
|
pub browser: Option<String>,
|
||||||
pub version: Option<String>,
|
pub version: Option<String>,
|
||||||
pub proxy_id: Option<String>,
|
pub proxy_id: Option<String>,
|
||||||
|
pub vpn_id: Option<String>,
|
||||||
pub launch_hook: Option<String>,
|
pub launch_hook: Option<String>,
|
||||||
pub release_type: Option<String>,
|
pub release_type: Option<String>,
|
||||||
#[schema(value_type = Object)]
|
#[schema(value_type = Object)]
|
||||||
@@ -140,6 +143,16 @@ struct ApiVpnResponse {
|
|||||||
last_used: Option<i64>,
|
last_used: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
struct ApiVpnExportResponse {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
/// Always "WireGuard"
|
||||||
|
vpn_type: String,
|
||||||
|
/// Raw `.conf` file content (decrypted)
|
||||||
|
config_data: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, ToSchema)]
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
struct ImportVpnRequest {
|
struct ImportVpnRequest {
|
||||||
/// Raw WireGuard `.conf` file content
|
/// Raw WireGuard `.conf` file content
|
||||||
@@ -357,6 +370,7 @@ impl ApiServer {
|
|||||||
.routes(routes!(get_proxy, update_proxy, delete_proxy))
|
.routes(routes!(get_proxy, update_proxy, delete_proxy))
|
||||||
.routes(routes!(get_vpns, create_vpn))
|
.routes(routes!(get_vpns, create_vpn))
|
||||||
.routes(routes!(import_vpn))
|
.routes(routes!(import_vpn))
|
||||||
|
.routes(routes!(export_vpn))
|
||||||
.routes(routes!(get_vpn, update_vpn, delete_vpn))
|
.routes(routes!(get_vpn, update_vpn, delete_vpn))
|
||||||
.routes(routes!(get_extensions))
|
.routes(routes!(get_extensions))
|
||||||
.routes(routes!(delete_extension_api))
|
.routes(routes!(delete_extension_api))
|
||||||
@@ -542,6 +556,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
|
|||||||
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
|
||||||
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
|
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
|
||||||
|
vpn_id: profile.vpn_id.clone(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -598,6 +613,7 @@ async fn get_profile(
|
|||||||
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
|
||||||
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
|
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
|
||||||
|
vpn_id: profile.vpn_id.clone(),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
@@ -652,7 +668,7 @@ async fn create_profile(
|
|||||||
&request.version,
|
&request.version,
|
||||||
request.release_type.as_deref().unwrap_or("stable"),
|
request.release_type.as_deref().unwrap_or("stable"),
|
||||||
request.proxy_id.clone(),
|
request.proxy_id.clone(),
|
||||||
None, // vpn_id
|
request.vpn_id.clone(),
|
||||||
camoufox_config,
|
camoufox_config,
|
||||||
wayfern_config,
|
wayfern_config,
|
||||||
request.group_id.clone(),
|
request.group_id.clone(),
|
||||||
@@ -700,6 +716,7 @@ async fn create_profile(
|
|||||||
tags: profile.tags,
|
tags: profile.tags,
|
||||||
is_running: false,
|
is_running: false,
|
||||||
proxy_bypass_rules: profile.proxy_bypass_rules,
|
proxy_bypass_rules: profile.proxy_bypass_rules,
|
||||||
|
vpn_id: profile.vpn_id,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -733,6 +750,12 @@ async fn update_profile(
|
|||||||
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
||||||
let profile_manager = ProfileManager::instance();
|
let profile_manager = ProfileManager::instance();
|
||||||
|
|
||||||
|
if request.proxy_id.as_deref().is_some_and(|s| !s.is_empty())
|
||||||
|
&& request.vpn_id.as_deref().is_some_and(|s| !s.is_empty())
|
||||||
|
{
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
// Update profile fields
|
// Update profile fields
|
||||||
if let Some(new_name) = request.name {
|
if let Some(new_name) = request.name {
|
||||||
if profile_manager
|
if profile_manager
|
||||||
@@ -762,6 +785,21 @@ async fn update_profile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(vpn_id) = request.vpn_id {
|
||||||
|
let normalized = if vpn_id.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(vpn_id)
|
||||||
|
};
|
||||||
|
if profile_manager
|
||||||
|
.update_profile_vpn(state.app_handle.clone(), &id, normalized)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(launch_hook) = request.launch_hook {
|
if let Some(launch_hook) = request.launch_hook {
|
||||||
let normalized = if launch_hook.trim().is_empty() {
|
let normalized = if launch_hook.trim().is_empty() {
|
||||||
None
|
None
|
||||||
@@ -1308,6 +1346,37 @@ async fn get_vpn(
|
|||||||
.ok_or(StatusCode::NOT_FOUND)
|
.ok_or(StatusCode::NOT_FOUND)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/v1/vpns/{id}/export",
|
||||||
|
params(("id" = String, Path, description = "VPN configuration ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Decrypted VPN configuration", body = ApiVpnExportResponse),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 404, description = "VPN configuration not found"),
|
||||||
|
(status = 500, description = "Internal server error")
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
tag = "vpns"
|
||||||
|
)]
|
||||||
|
async fn export_vpn(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
State(_state): State<ApiServerState>,
|
||||||
|
) -> Result<Json<ApiVpnExportResponse>, StatusCode> {
|
||||||
|
let storage = crate::vpn::VPN_STORAGE
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
match storage.load_config(&id) {
|
||||||
|
Ok(config) => Ok(Json(ApiVpnExportResponse {
|
||||||
|
id: config.id,
|
||||||
|
name: config.name,
|
||||||
|
vpn_type: config.vpn_type.to_string(),
|
||||||
|
config_data: config.config_data,
|
||||||
|
})),
|
||||||
|
Err(_) => Err(StatusCode::NOT_FOUND),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/v1/vpns/import",
|
path = "/v1/vpns/import",
|
||||||
|
|||||||
@@ -928,18 +928,35 @@ impl AppAutoUpdater {
|
|||||||
// Move new app to current location
|
// Move new app to current location
|
||||||
fs::rename(installer_path, ¤t_app_path)?;
|
fs::rename(installer_path, ¤t_app_path)?;
|
||||||
|
|
||||||
// Remove quarantine attributes from the new app
|
// Remove the macOS quarantine attribute from the freshly-installed app
|
||||||
let _ = Command::new("xattr")
|
// so Gatekeeper doesn't block its first launch — but only if it's
|
||||||
.args([
|
// actually present. macOS Sequoia's App Management TCC fires on the
|
||||||
"-dr",
|
// modify-class syscall regardless of whether anything is actually
|
||||||
"com.apple.quarantine",
|
// modified, so we gate the call behind a read-only `getxattr` check.
|
||||||
current_app_path.to_str().unwrap(),
|
let needs_quarantine_removal = {
|
||||||
])
|
use std::ffi::CString;
|
||||||
.output();
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
let path_c = CString::new(current_app_path.as_os_str().as_bytes()).ok();
|
||||||
let _ = Command::new("xattr")
|
let attr_c = CString::new("com.apple.quarantine").ok();
|
||||||
.args(["-cr", current_app_path.to_str().unwrap()])
|
match (path_c, attr_c) {
|
||||||
.output();
|
(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
|
// Clean up backup after successful installation
|
||||||
let _ = fs::remove_dir_all(&backup_path);
|
let _ = fs::remove_dir_all(&backup_path);
|
||||||
|
|||||||
@@ -127,8 +127,16 @@ lazy_static! {
|
|||||||
impl CloudAuthManager {
|
impl CloudAuthManager {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
let state = Self::load_auth_state_from_disk();
|
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 {
|
Self {
|
||||||
client: Client::new(),
|
client,
|
||||||
state: Mutex::new(state),
|
state: Mutex::new(state),
|
||||||
refresh_lock: tokio::sync::Mutex::new(()),
|
refresh_lock: tokio::sync::Mutex::new(()),
|
||||||
wayfern_token: Mutex::new(None),
|
wayfern_token: Mutex::new(None),
|
||||||
@@ -990,7 +998,15 @@ impl CloudAuthManager {
|
|||||||
let token = self
|
let token = self
|
||||||
.api_call_with_retry(|access_token| {
|
.api_call_with_retry(|access_token| {
|
||||||
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
|
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
|
||||||
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 {
|
async move {
|
||||||
let response = client
|
let response = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
|
|||||||
+70
-23
@@ -12,6 +12,39 @@ use tokio::process::Command;
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use std::fs::create_dir_all;
|
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;
|
pub struct Extractor;
|
||||||
|
|
||||||
impl Extractor {
|
impl Extractor {
|
||||||
@@ -207,18 +240,23 @@ impl Extractor {
|
|||||||
|
|
||||||
match extraction_result {
|
match extraction_result {
|
||||||
Ok(path) => {
|
Ok(path) => {
|
||||||
// Remove quarantine attributes on macOS to prevent
|
// Remove quarantine attributes on macOS to prevent Gatekeeper prompts —
|
||||||
// "app was prevented from modifying data" 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")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
let _ = tokio::process::Command::new("xattr")
|
if has_quarantine_attr(dest_dir) {
|
||||||
.args([
|
let _ = tokio::process::Command::new("xattr")
|
||||||
"-dr",
|
.args([
|
||||||
"com.apple.quarantine",
|
"-dr",
|
||||||
dest_dir.to_str().unwrap_or("."),
|
"com.apple.quarantine",
|
||||||
])
|
dest_dir.to_str().unwrap_or("."),
|
||||||
.output()
|
])
|
||||||
.await;
|
.output()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -419,9 +457,15 @@ impl Extractor {
|
|||||||
|
|
||||||
log::info!("Copying .app to: {}", app_path.display());
|
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")
|
let output = Command::new("cp")
|
||||||
.args([
|
.args([
|
||||||
"-R",
|
"-RX",
|
||||||
app_entry.to_str().unwrap(),
|
app_entry.to_str().unwrap(),
|
||||||
app_path.to_str().unwrap(),
|
app_path.to_str().unwrap(),
|
||||||
])
|
])
|
||||||
@@ -444,18 +488,21 @@ impl Extractor {
|
|||||||
|
|
||||||
log::info!("Successfully copied .app bundle");
|
log::info!("Successfully copied .app bundle");
|
||||||
|
|
||||||
// Remove quarantine attributes
|
// Remove the macOS quarantine attribute so Gatekeeper doesn't block launch
|
||||||
let _ = Command::new("xattr")
|
// — but only if it's actually present. A no-op `removexattr` syscall on a
|
||||||
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
|
// signed .app bundle still trips macOS Sequoia's App Management privacy
|
||||||
.output()
|
// prompt ("Donut.app was prevented from modifying apps on your Mac"),
|
||||||
.await;
|
// even when no modification actually happens, so we gate the call behind
|
||||||
|
// a read-only `getxattr` check.
|
||||||
let _ = Command::new("xattr")
|
if has_quarantine_attr(&app_path) {
|
||||||
.args(["-cr", app_path.to_str().unwrap()])
|
let _ = Command::new("xattr")
|
||||||
.output()
|
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
|
||||||
.await;
|
.output()
|
||||||
|
.await;
|
||||||
log::info!("Removed quarantine attributes");
|
log::info!("Removed quarantine attributes");
|
||||||
|
} else {
|
||||||
|
log::info!("No quarantine attribute on .app, skipping xattr removal");
|
||||||
|
}
|
||||||
|
|
||||||
// Unmount the DMG
|
// Unmount the DMG
|
||||||
let output = Command::new("hdiutil")
|
let output = Command::new("hdiutil")
|
||||||
|
|||||||
+31
-15
@@ -675,11 +675,17 @@ fn find_claude_cli() -> Option<std::path::PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn is_mcp_in_claude_code() -> Result<bool, String> {
|
async fn is_mcp_in_claude_code() -> Result<bool, String> {
|
||||||
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
|
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
|
||||||
let output = std::process::Command::new(&cli)
|
// `claude mcp list` health-checks every registered MCP server, so a
|
||||||
|
// missing or stalled server can hang the call for many seconds. Cap it
|
||||||
|
// — for this dialog, a slow `claude` is treated the same as "not registered".
|
||||||
|
let fut = tokio::process::Command::new(&cli)
|
||||||
.args(["mcp", "list"])
|
.args(["mcp", "list"])
|
||||||
.output()
|
.output();
|
||||||
|
let output = tokio::time::timeout(std::time::Duration::from_secs(2), fut)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "claude mcp list timed out".to_string())?
|
||||||
.map_err(|e| format!("Failed to run claude: {e}"))?;
|
.map_err(|e| format!("Failed to run claude: {e}"))?;
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
Ok(stdout.contains("donut-browser"))
|
Ok(stdout.contains("donut-browser"))
|
||||||
@@ -1232,7 +1238,7 @@ pub fn run() {
|
|||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
||||||
.title("Donut Browser")
|
.title("Donut Browser")
|
||||||
.inner_size(800.0, 500.0)
|
.inner_size(840.0, 500.0)
|
||||||
.resizable(false)
|
.resizable(false)
|
||||||
.fullscreen(false)
|
.fullscreen(false)
|
||||||
.center()
|
.center()
|
||||||
@@ -1882,21 +1888,31 @@ pub fn run() {
|
|||||||
// Start cloud auth background refresh loop
|
// Start cloud auth background refresh loop
|
||||||
let app_handle_cloud = app.handle().clone();
|
let app_handle_cloud = app.handle().clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
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
|
// api_call_with_retry handles 401/refresh internally — no direct
|
||||||
// refresh_access_token call needed.
|
// refresh_access_token call needed.
|
||||||
if cloud_auth::CLOUD_AUTH.is_logged_in().await {
|
if cloud_auth::CLOUD_AUTH.is_logged_in().await {
|
||||||
if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await {
|
let sync_token_fut = async {
|
||||||
log::warn!("Failed to refresh cloud sync token on startup: {e}");
|
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 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;
|
cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -174,6 +174,10 @@ pub struct ProxyManager {
|
|||||||
// Track active proxy IDs by profile name for targeted cleanup
|
// Track active proxy IDs by profile name for targeted cleanup
|
||||||
profile_active_proxy_ids: Mutex<HashMap<String, String>>, // Maps profile name to proxy id
|
profile_active_proxy_ids: Mutex<HashMap<String, String>>, // Maps profile name to proxy id
|
||||||
stored_proxies: Mutex<HashMap<String, StoredProxy>>, // Maps proxy ID to stored proxy
|
stored_proxies: Mutex<HashMap<String, StoredProxy>>, // 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<HashMap<u32, u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProxyManager {
|
impl ProxyManager {
|
||||||
@@ -183,6 +187,7 @@ impl ProxyManager {
|
|||||||
profile_proxies: Mutex::new(HashMap::new()),
|
profile_proxies: Mutex::new(HashMap::new()),
|
||||||
profile_active_proxy_ids: Mutex::new(HashMap::new()),
|
profile_active_proxy_ids: Mutex::new(HashMap::new()),
|
||||||
stored_proxies: Mutex::new(HashMap::new()),
|
stored_proxies: Mutex::new(HashMap::new()),
|
||||||
|
dead_browser_misses: Mutex::new(HashMap::new()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load stored proxies on initialization
|
// Load stored proxies on initialization
|
||||||
@@ -2095,17 +2100,52 @@ impl ProxyManager {
|
|||||||
sysinfo::RefreshKind::nothing().with_processes(sysinfo::ProcessRefreshKind::everything()),
|
sysinfo::RefreshKind::nothing().with_processes(sysinfo::ProcessRefreshKind::everything()),
|
||||||
);
|
);
|
||||||
|
|
||||||
let dead_browser_entries: Vec<(u32, String, Option<String>)> = snapshot
|
// Two-state classification: alive PIDs reset their miss counter,
|
||||||
.into_iter()
|
// dead PIDs increment it. A worker is only reaped after MISS_THRESHOLD
|
||||||
.filter(|(browser_pid, _, _)| {
|
// consecutive misses (~60s by default given the 30s cleanup cadence),
|
||||||
// The sentinel PID=0 is used as a placeholder during launch,
|
// so a single sysinfo blip under heavy load doesn't kill a healthy worker.
|
||||||
// before update_proxy_pid has recorded the real browser PID.
|
const MISS_THRESHOLD: u8 = 2;
|
||||||
*browser_pid != 0
|
|
||||||
&& system
|
let mut alive_pids: Vec<u32> = Vec::new();
|
||||||
.process(sysinfo::Pid::from_u32(*browser_pid))
|
let mut dead_candidates: Vec<(u32, String, Option<String>)> = Vec::new();
|
||||||
.is_none()
|
let mut snapshot_pids: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||||
})
|
for (browser_pid, proxy_id, profile_id) in snapshot {
|
||||||
.collect();
|
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<String>)> = {
|
||||||
|
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 {
|
for (browser_pid, proxy_id, profile_id) in dead_browser_entries {
|
||||||
log::info!(
|
log::info!(
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::task::{Context, Poll};
|
use std::task::{Context, Poll};
|
||||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
|
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
|
||||||
use tokio::net::TcpListener;
|
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
/// Combined read+write trait for tunnel target streams, allowing
|
/// Combined read+write trait for tunnel target streams, allowing
|
||||||
@@ -1232,8 +1231,49 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
|||||||
|
|
||||||
log::error!("Attempting to bind proxy server to {}", bind_addr);
|
log::error!("Attempting to bind proxy server to {}", bind_addr);
|
||||||
|
|
||||||
// Bind to the port
|
// Bind to the port. Use SO_REUSEADDR so that a freshly-restarted worker
|
||||||
let listener = TcpListener::bind(bind_addr).await?;
|
// can bind a port that the previous worker left in TIME_WAIT, and retry
|
||||||
|
// briefly to absorb transient races with the OS releasing the socket.
|
||||||
|
let listener = {
|
||||||
|
let mut attempts: u32 = 0;
|
||||||
|
loop {
|
||||||
|
let socket = tokio::net::TcpSocket::new_v4()?;
|
||||||
|
let _ = socket.set_reuseaddr(true);
|
||||||
|
match socket.bind(bind_addr) {
|
||||||
|
Ok(()) => 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();
|
let actual_port = listener.local_addr()?.port();
|
||||||
|
|
||||||
log::error!("Successfully bound to port {}", actual_port);
|
log::error!("Successfully bound to port {}", actual_port);
|
||||||
@@ -1295,52 +1335,54 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
if let Some(tracker) = get_traffic_tracker() {
|
// Catch panics so a poisoned lock or unexpected error inside
|
||||||
let (sent, recv, requests) = tracker.get_snapshot();
|
// flush_to_disk doesn't abort the flush task and leave stats
|
||||||
let current_bytes = sent + recv;
|
// unwritten for the lifetime of the worker. The captured state
|
||||||
let time_since_activity = last_activity_time.elapsed();
|
// is all Copy or atomic-assignment, so AssertUnwindSafe is sound.
|
||||||
let time_since_flush = last_flush_time.elapsed();
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
let has_traffic = current_bytes > 0 || requests > 0;
|
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
|
let desired_interval_secs =
|
||||||
// When active: flush every 5 seconds
|
if has_traffic || time_since_activity < std::time::Duration::from_secs(30) {
|
||||||
// When idle: flush every 30 seconds
|
5u64
|
||||||
let desired_interval_secs =
|
} else {
|
||||||
if has_traffic || time_since_activity < std::time::Duration::from_secs(30) {
|
30u64
|
||||||
5u64
|
};
|
||||||
} else {
|
|
||||||
30u64
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update interval if needed
|
if desired_interval_secs != current_interval_secs {
|
||||||
if desired_interval_secs != current_interval_secs {
|
current_interval_secs = desired_interval_secs;
|
||||||
current_interval_secs = desired_interval_secs;
|
interval =
|
||||||
interval = tokio::time::interval(tokio::time::Duration::from_secs(desired_interval_secs));
|
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 flush_interval = std::time::Duration::from_secs(desired_interval_secs);
|
let should_flush = time_since_flush >= flush_interval;
|
||||||
let should_flush = time_since_flush >= flush_interval;
|
|
||||||
|
|
||||||
if should_flush {
|
if should_flush {
|
||||||
match tracker.flush_to_disk() {
|
match tracker.flush_to_disk() {
|
||||||
Ok(Some((sent, recv))) => {
|
Ok(Some((sent, recv))) => {
|
||||||
// Successful flush with data
|
last_flush_time = std::time::Instant::now();
|
||||||
last_flush_time = std::time::Instant::now();
|
if sent > 0 || recv > 0 {
|
||||||
if sent > 0 || recv > 0 {
|
last_activity_time = std::time::Instant::now();
|
||||||
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:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -639,14 +639,25 @@ impl WayfernManager {
|
|||||||
.has_active_paid_subscription()
|
.has_active_paid_subscription()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
log::info!("Wayfern token not ready for paid user, waiting...");
|
// Brief wait for the background token fetch — when the API is healthy
|
||||||
for _ in 0..15 {
|
// 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;
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
||||||
if wayfern_token.is_some() {
|
if wayfern_token.is_some() {
|
||||||
break;
|
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 {
|
if let Some(ref token) = wayfern_token {
|
||||||
args.push(format!("--wayfern-token={token}"));
|
args.push(format!("--wayfern-token={token}"));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Donut",
|
"productName": "Donut",
|
||||||
"version": "0.22.4",
|
"version": "0.22.7",
|
||||||
"identifier": "com.donutbrowser",
|
"identifier": "com.donutbrowser",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||||
@@ -42,11 +42,11 @@
|
|||||||
"linux": {
|
"linux": {
|
||||||
"deb": {
|
"deb": {
|
||||||
"desktopTemplate": "donutbrowser.desktop",
|
"desktopTemplate": "donutbrowser.desktop",
|
||||||
"depends": ["xdg-utils"]
|
"depends": ["xdg-utils", "libxdo3"]
|
||||||
},
|
},
|
||||||
"rpm": {
|
"rpm": {
|
||||||
"desktopTemplate": "donutbrowser.desktop",
|
"desktopTemplate": "donutbrowser.desktop",
|
||||||
"depends": ["xdg-utils"]
|
"depends": ["xdg-utils", "libxdo"]
|
||||||
},
|
},
|
||||||
"appimage": {
|
"appimage": {
|
||||||
"files": {
|
"files": {
|
||||||
|
|||||||
+49
-15
@@ -12,6 +12,7 @@ import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
|||||||
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
|
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
|
||||||
import { CreateProfileDialog } from "@/components/create-profile-dialog";
|
import { CreateProfileDialog } from "@/components/create-profile-dialog";
|
||||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-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 { ExtensionGroupAssignmentDialog } from "@/components/extension-group-assignment-dialog";
|
||||||
import { ExtensionManagementDialog } from "@/components/extension-management-dialog";
|
import { ExtensionManagementDialog } from "@/components/extension-management-dialog";
|
||||||
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
|
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
|
||||||
@@ -197,6 +198,7 @@ export default function Home() {
|
|||||||
useState(false);
|
useState(false);
|
||||||
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
||||||
const [syncConfigDialogOpen, setSyncConfigDialogOpen] = useState(false);
|
const [syncConfigDialogOpen, setSyncConfigDialogOpen] = useState(false);
|
||||||
|
const [deviceCodeDialogOpen, setDeviceCodeDialogOpen] = useState(false);
|
||||||
const [syncAllDialogOpen, setSyncAllDialogOpen] = useState(false);
|
const [syncAllDialogOpen, setSyncAllDialogOpen] = useState(false);
|
||||||
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
|
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
|
||||||
const [currentProfileForSync, setCurrentProfileForSync] =
|
const [currentProfileForSync, setCurrentProfileForSync] =
|
||||||
@@ -394,21 +396,32 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, [isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized]);
|
}, [isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized]);
|
||||||
|
|
||||||
const checkNextPermission = useCallback(() => {
|
const checkNextPermission = useCallback(
|
||||||
try {
|
(justGranted?: PermissionType) => {
|
||||||
if (!isMicrophoneAccessGranted) {
|
try {
|
||||||
setCurrentPermissionType("microphone");
|
// Treat the just-granted permission as already granted even if our
|
||||||
setPermissionDialogOpen(true);
|
// own usePermissions instance hasn't observed it yet — it polls on a
|
||||||
} else if (!isCameraAccessGranted) {
|
// 5 s cadence and would otherwise leave the dialog stuck on the
|
||||||
setCurrentPermissionType("camera");
|
// permission the user just successfully granted.
|
||||||
setPermissionDialogOpen(true);
|
const micGranted =
|
||||||
} else {
|
isMicrophoneAccessGranted || justGranted === "microphone";
|
||||||
setPermissionDialogOpen(false);
|
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 () => {
|
const listenForUrlEvents = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1070,7 +1083,7 @@ export default function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid items-center justify-items-center min-h-screen gap-8 font-(family-name:--font-geist-sans) bg-background">
|
<div className="grid items-center justify-items-center min-h-screen gap-8 font-(family-name:--font-geist-sans) bg-background">
|
||||||
<main className="flex flex-col items-center w-full max-w-3xl">
|
<main className="flex flex-col items-center w-full max-w-4xl px-3">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<HomeHeader
|
<HomeHeader
|
||||||
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
||||||
@@ -1316,8 +1329,29 @@ export default function Home() {
|
|||||||
setSyncAllDialogOpen(true);
|
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 && (
|
||||||
|
<DeviceCodeVerifyDialog
|
||||||
|
isOpen={deviceCodeDialogOpen}
|
||||||
|
onClose={(loginOccurred) => {
|
||||||
|
setDeviceCodeDialogOpen(false);
|
||||||
|
if (loginOccurred) {
|
||||||
|
setSyncAllDialogOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<SyncAllDialog
|
<SyncAllDialog
|
||||||
isOpen={syncAllDialogOpen}
|
isOpen={syncAllDialogOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { FaExternalLinkAlt, FaTimes } from "react-icons/fa";
|
import { FaExternalLinkAlt, FaTimes } from "react-icons/fa";
|
||||||
import { LuCheckCheck } from "react-icons/lu";
|
import { LuCheckCheck } from "react-icons/lu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -19,6 +20,7 @@ export function AppUpdateToast({
|
|||||||
onDismiss,
|
onDismiss,
|
||||||
updateReady = false,
|
updateReady = false,
|
||||||
}: AppUpdateToastProps) {
|
}: AppUpdateToastProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const handleRestartClick = async () => {
|
const handleRestartClick = async () => {
|
||||||
await onRestart();
|
await onRestart();
|
||||||
};
|
};
|
||||||
@@ -43,10 +45,10 @@ export function AppUpdateToast({
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="text-sm font-semibold text-foreground">
|
||||||
{updateReady
|
{updateReady
|
||||||
? "Update ready, restart to apply"
|
? t("appUpdate.toast.updateReady")
|
||||||
: updateInfo.repo_update
|
: updateInfo.repo_update
|
||||||
? "Update available via package manager"
|
? "Update available via package manager"
|
||||||
: "Manual download required"}
|
: t("appUpdate.toast.manualDownloadRequired")}
|
||||||
</span>
|
</span>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{updateInfo.current_version} → {updateInfo.new_version}
|
{updateInfo.current_version} → {updateInfo.new_version}
|
||||||
@@ -71,7 +73,7 @@ export function AppUpdateToast({
|
|||||||
className="flex gap-2 items-center text-xs"
|
className="flex gap-2 items-center text-xs"
|
||||||
>
|
>
|
||||||
<LuCheckCheck className="w-3 h-3" />
|
<LuCheckCheck className="w-3 h-3" />
|
||||||
Restart Now
|
{t("appUpdate.toast.restartNow")}
|
||||||
</RippleButton>
|
</RippleButton>
|
||||||
) : (
|
) : (
|
||||||
!updateInfo.repo_update &&
|
!updateInfo.repo_update &&
|
||||||
@@ -82,7 +84,7 @@ export function AppUpdateToast({
|
|||||||
className="flex gap-2 items-center text-xs"
|
className="flex gap-2 items-center text-xs"
|
||||||
>
|
>
|
||||||
<FaExternalLinkAlt className="w-3 h-3" />
|
<FaExternalLinkAlt className="w-3 h-3" />
|
||||||
View Release
|
{t("appUpdate.toast.viewRelease")}
|
||||||
</RippleButton>
|
</RippleButton>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -92,7 +94,7 @@ export function AppUpdateToast({
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
Later
|
{t("appUpdate.toast.later")}
|
||||||
</RippleButton>
|
</RippleButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -537,7 +537,7 @@ export function CreateProfileDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent className="w-full max-h-[90vh] flex flex-col">
|
<DialogContent className="max-w-md max-h-[90vh] flex flex-col">
|
||||||
<DialogHeader className="flex-shrink-0">
|
<DialogHeader className="flex-shrink-0">
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{currentStep === "browser-selection"
|
{currentStep === "browser-selection"
|
||||||
@@ -953,7 +953,7 @@ export function CreateProfileDialog({
|
|||||||
<div className="flex gap-3 items-center">
|
<div className="flex gap-3 items-center">
|
||||||
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
<div className="w-4 h-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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -294,7 +294,9 @@ export function UnifiedToast(props: ToastProps) {
|
|||||||
"completed_files" in progress && (
|
"completed_files" in progress && (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{progress.phase === "uploading" ? "Uploading" : "Downloading"}{" "}
|
{progress.phase === "uploading"
|
||||||
|
? t("appUpdate.toast.uploading")
|
||||||
|
: t("appUpdate.toast.downloading")}{" "}
|
||||||
{progress.completed_files}/{progress.total_files} files
|
{progress.completed_files}/{progress.total_files} files
|
||||||
{" \u2022 "}
|
{" \u2022 "}
|
||||||
{formatBytesCompact(progress.completed_bytes)} /{" "}
|
{formatBytesCompact(progress.completed_bytes)} /{" "}
|
||||||
@@ -349,17 +351,17 @@ export function UnifiedToast(props: ToastProps) {
|
|||||||
<>
|
<>
|
||||||
{stage === "extracting" && (
|
{stage === "extracting" && (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Extracting browser files... Please do not close the app.
|
{t("browserDownload.toast.extracting")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{stage === "verifying" && (
|
{stage === "verifying" && (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Verifying browser files...
|
{t("browserDownload.toast.verifying")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{stage === "downloading (twilight rolling release)" && (
|
{stage === "downloading (twilight rolling release)" && (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Downloading rolling release build...
|
{t("browserDownload.toast.downloadingRolling")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) onClose(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("sync.cloud.verifyAndLogin")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("sync.cloud.deviceLinkInstructions")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="device-link-code">
|
||||||
|
{t("sync.cloud.linkCodeLabel")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="device-link-code"
|
||||||
|
placeholder={t("sync.cloud.linkCodePlaceholder")}
|
||||||
|
value={linkCode}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLinkCode(e.target.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && linkCode.trim()) {
|
||||||
|
void handleVerify();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<LoadingButton
|
||||||
|
onClick={() => void handleVerify()}
|
||||||
|
isLoading={isVerifying}
|
||||||
|
disabled={!linkCode.trim()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isVerifying
|
||||||
|
? t("sync.cloud.loggingIn")
|
||||||
|
: t("sync.cloud.verifyAndLogin")}
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -55,12 +55,12 @@ export function ExtensionGroupAssignmentDialog({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load extension groups:", err);
|
console.error("Failed to load extension groups:", err);
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error ? err.message : "Failed to load extension groups",
|
err instanceof Error ? err.message : t("extensions.loadGroupsFailed"),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
const handleAssign = useCallback(async () => {
|
const handleAssign = useCallback(async () => {
|
||||||
setIsAssigning(true);
|
setIsAssigning(true);
|
||||||
@@ -79,7 +79,7 @@ export function ExtensionGroupAssignmentDialog({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to assign extension group:", err);
|
console.error("Failed to assign extension group:", err);
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
err instanceof Error ? err.message : "Failed to assign extension group";
|
err instanceof Error ? err.message : t("extensions.assignGroupFailed");
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -50,36 +50,43 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
|
|||||||
function getSyncStatusDot(
|
function getSyncStatusDot(
|
||||||
item: { sync_enabled?: boolean; last_sync?: number },
|
item: { sync_enabled?: boolean; last_sync?: number },
|
||||||
liveStatus: SyncStatus | undefined,
|
liveStatus: SyncStatus | undefined,
|
||||||
|
t: (key: string, options?: Record<string, unknown>) => string,
|
||||||
): { color: string; tooltip: string; animate: boolean } {
|
): { color: string; tooltip: string; animate: boolean } {
|
||||||
const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled");
|
const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled");
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "syncing":
|
case "syncing":
|
||||||
return { color: "bg-warning", tooltip: "Syncing...", animate: true };
|
return {
|
||||||
|
color: "bg-warning",
|
||||||
|
tooltip: t("profileTable.syncTooltipSyncing"),
|
||||||
|
animate: true,
|
||||||
|
};
|
||||||
case "synced":
|
case "synced":
|
||||||
return {
|
return {
|
||||||
color: "bg-success",
|
color: "bg-success",
|
||||||
tooltip: item.last_sync
|
tooltip: item.last_sync
|
||||||
? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}`
|
? t("profileTable.syncTooltipSyncedAt", {
|
||||||
: "Synced",
|
time: new Date(item.last_sync * 1000).toLocaleString(),
|
||||||
|
})
|
||||||
|
: t("profileTable.syncTooltipSynced"),
|
||||||
animate: false,
|
animate: false,
|
||||||
};
|
};
|
||||||
case "waiting":
|
case "waiting":
|
||||||
return {
|
return {
|
||||||
color: "bg-warning",
|
color: "bg-warning",
|
||||||
tooltip: "Waiting to sync",
|
tooltip: t("profileTable.syncTooltipWaiting"),
|
||||||
animate: false,
|
animate: false,
|
||||||
};
|
};
|
||||||
case "error":
|
case "error":
|
||||||
return {
|
return {
|
||||||
color: "bg-destructive",
|
color: "bg-destructive",
|
||||||
tooltip: "Sync error",
|
tooltip: t("profileTable.syncTooltipError"),
|
||||||
animate: false,
|
animate: false,
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
color: "bg-muted-foreground",
|
color: "bg-muted-foreground",
|
||||||
tooltip: "Not synced",
|
tooltip: t("profileTable.syncTooltipNotSynced"),
|
||||||
animate: false,
|
animate: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -674,6 +681,7 @@ export function ExtensionManagementDialog({
|
|||||||
const syncDot = getSyncStatusDot(
|
const syncDot = getSyncStatusDot(
|
||||||
ext,
|
ext,
|
||||||
extSyncStatus[ext.id],
|
extSyncStatus[ext.id],
|
||||||
|
t,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -840,6 +848,7 @@ export function ExtensionManagementDialog({
|
|||||||
const groupSyncDot = getSyncStatusDot(
|
const groupSyncDot = getSyncStatusDot(
|
||||||
group,
|
group,
|
||||||
extSyncStatus[group.id],
|
extSyncStatus[group.id],
|
||||||
|
t,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -995,7 +1004,7 @@ export function ExtensionManagementDialog({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="max-w-lg max-h-[90vh] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("extensions.editGroup")}</DialogTitle>
|
<DialogTitle>{t("extensions.editGroup")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -1003,87 +1012,89 @@ export function ExtensionManagementDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<ScrollArea className="overflow-y-auto flex-1 -mx-6 px-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<Label>{t("common.labels.name")}</Label>
|
|
||||||
<Input
|
|
||||||
value={editGroupName}
|
|
||||||
onChange={(e) => {
|
|
||||||
setEditGroupName(e.target.value);
|
|
||||||
}}
|
|
||||||
placeholder={t("extensions.groupNamePlaceholder")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{extensions.filter((e) => !editGroupExtensionIds.includes(e.id))
|
|
||||||
.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("extensions.addToGroup")}</Label>
|
<Label>{t("common.labels.name")}</Label>
|
||||||
<Select
|
<Input
|
||||||
value=""
|
value={editGroupName}
|
||||||
onValueChange={(extId) => {
|
onChange={(e) => {
|
||||||
setEditGroupExtensionIds((prev) => [...prev, extId]);
|
setEditGroupName(e.target.value);
|
||||||
}}
|
}}
|
||||||
>
|
placeholder={t("extensions.groupNamePlaceholder")}
|
||||||
<SelectTrigger>
|
/>
|
||||||
<SelectValue placeholder={t("extensions.addToGroup")} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{extensions
|
|
||||||
.filter((e) => !editGroupExtensionIds.includes(e.id))
|
|
||||||
.map((ext) => (
|
|
||||||
<SelectItem key={ext.id} value={ext.id}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{renderExtensionIcon(ext, "sm")}
|
|
||||||
{ext.name}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
{extensions.filter((e) => !editGroupExtensionIds.includes(e.id))
|
||||||
<Label>{t("extensions.groupExtensions")}</Label>
|
.length > 0 && (
|
||||||
{editGroupExtensionIds.length === 0 ? (
|
<div className="space-y-2">
|
||||||
<div className="text-sm text-muted-foreground py-2">
|
<Label>{t("extensions.addToGroup")}</Label>
|
||||||
{t("extensions.noExtensionsInGroup")}
|
<Select
|
||||||
</div>
|
value=""
|
||||||
) : (
|
onValueChange={(extId) => {
|
||||||
<div className="space-y-1 max-h-[200px] overflow-y-auto">
|
setEditGroupExtensionIds((prev) => [...prev, extId]);
|
||||||
{editGroupExtensionIds.map((extId) => {
|
}}
|
||||||
const ext = extensions.find((e) => e.id === extId);
|
>
|
||||||
if (!ext) return null;
|
<SelectTrigger>
|
||||||
return (
|
<SelectValue placeholder={t("extensions.addToGroup")} />
|
||||||
<div
|
</SelectTrigger>
|
||||||
key={extId}
|
<SelectContent>
|
||||||
className="flex items-center gap-2 rounded-md border px-2 py-1.5"
|
{extensions
|
||||||
>
|
.filter((e) => !editGroupExtensionIds.includes(e.id))
|
||||||
{renderExtensionIcon(ext, "sm")}
|
.map((ext) => (
|
||||||
<span className="text-sm flex-1 truncate min-w-0">
|
<SelectItem key={ext.id} value={ext.id}>
|
||||||
{ext.name}
|
<div className="flex items-center gap-2">
|
||||||
</span>
|
{renderExtensionIcon(ext, "sm")}
|
||||||
{renderCompatIcons(ext.browser_compatibility)}
|
{ext.name}
|
||||||
<Button
|
</div>
|
||||||
variant="ghost"
|
</SelectItem>
|
||||||
size="sm"
|
))}
|
||||||
className="h-6 w-6 p-0 shrink-0"
|
</SelectContent>
|
||||||
onClick={() => {
|
</Select>
|
||||||
setEditGroupExtensionIds((prev) =>
|
|
||||||
prev.filter((id) => id !== extId),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LuTrash2 className="w-3 h-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("extensions.groupExtensions")}</Label>
|
||||||
|
{editGroupExtensionIds.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground py-2">
|
||||||
|
{t("extensions.noExtensionsInGroup")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 max-h-[200px] overflow-y-auto">
|
||||||
|
{editGroupExtensionIds.map((extId) => {
|
||||||
|
const ext = extensions.find((e) => e.id === extId);
|
||||||
|
if (!ext) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={extId}
|
||||||
|
className="flex items-center gap-2 rounded-md border px-2 py-1.5"
|
||||||
|
>
|
||||||
|
{renderExtensionIcon(ext, "sm")}
|
||||||
|
<span className="text-sm flex-1 truncate min-w-0">
|
||||||
|
{ext.name}
|
||||||
|
</span>
|
||||||
|
{renderCompatIcons(ext.browser_compatibility)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 shrink-0"
|
||||||
|
onClick={() => {
|
||||||
|
setEditGroupExtensionIds((prev) =>
|
||||||
|
prev.filter((id) => id !== extId),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuTrash2 className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScrollArea>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
@@ -1117,7 +1128,7 @@ export function ExtensionManagementDialog({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="max-w-lg max-h-[90vh] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("extensions.editExtension")}</DialogTitle>
|
<DialogTitle>{t("extensions.editExtension")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -1125,123 +1136,127 @@ export function ExtensionManagementDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{editingExtension && (
|
<ScrollArea className="overflow-y-auto flex-1 -mx-6 px-6">
|
||||||
<div className="space-y-4">
|
{editingExtension && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<Label>{t("common.labels.name")}</Label>
|
<div className="space-y-2">
|
||||||
<Input
|
<Label>{t("common.labels.name")}</Label>
|
||||||
value={editExtensionName}
|
<Input
|
||||||
onChange={(e) => {
|
value={editExtensionName}
|
||||||
setEditExtensionName(e.target.value);
|
onChange={(e) => {
|
||||||
}}
|
setEditExtensionName(e.target.value);
|
||||||
placeholder={t("extensions.namePlaceholder")}
|
}}
|
||||||
onKeyDown={(e) => {
|
placeholder={t("extensions.namePlaceholder")}
|
||||||
if (e.key === "Enter") void handleUpdateExtension();
|
onKeyDown={(e) => {
|
||||||
}}
|
if (e.key === "Enter") void handleUpdateExtension();
|
||||||
/>
|
}}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Metadata from manifest.json */}
|
{/* Metadata from manifest.json */}
|
||||||
<div className="rounded-md border p-3 space-y-2">
|
<div className="rounded-md border p-3 space-y-2">
|
||||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||||
{t("extensions.metadata")}
|
{t("extensions.metadata")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1.5 text-sm">
|
<div className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1.5 text-sm">
|
||||||
{editingExtension.version && (
|
{editingExtension.version && (
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{t("extensions.version")}
|
{t("extensions.version")}
|
||||||
</span>
|
|
||||||
<span>{editingExtension.version}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{editingExtension.author && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t("extensions.author")}
|
|
||||||
</span>
|
|
||||||
<span>{editingExtension.author}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{editingExtension.description && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t("common.labels.description")}
|
|
||||||
</span>
|
|
||||||
<span className="line-clamp-3">
|
|
||||||
{editingExtension.description}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t("extensions.compatibility.label")}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{renderCompatIcons(editingExtension.browser_compatibility)}
|
|
||||||
</div>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t("common.labels.type")}
|
|
||||||
</span>
|
|
||||||
<span>.{editingExtension.file_type}</span>
|
|
||||||
{editingExtension.homepage_url && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t("extensions.homepage")}
|
|
||||||
</span>
|
|
||||||
<a
|
|
||||||
href={editingExtension.homepage_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary hover:underline flex items-center gap-1 truncate"
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
{editingExtension.homepage_url}
|
|
||||||
</span>
|
</span>
|
||||||
<LuExternalLink className="w-3 h-3 shrink-0" />
|
<span>{editingExtension.version}</span>
|
||||||
</a>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
{editingExtension.author && (
|
||||||
{!editingExtension.version &&
|
<>
|
||||||
!editingExtension.author &&
|
<span className="text-muted-foreground">
|
||||||
!editingExtension.description &&
|
{t("extensions.author")}
|
||||||
!editingExtension.homepage_url && (
|
</span>
|
||||||
<span className="col-span-2 text-muted-foreground text-xs">
|
<span>{editingExtension.author}</span>
|
||||||
{t("extensions.noMetadata")}
|
</>
|
||||||
|
)}
|
||||||
|
{editingExtension.description && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("common.labels.description")}
|
||||||
|
</span>
|
||||||
|
<span className="line-clamp-3">
|
||||||
|
{editingExtension.description}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("extensions.compatibility.label")}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{renderCompatIcons(
|
||||||
|
editingExtension.browser_compatibility,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("common.labels.type")}
|
||||||
|
</span>
|
||||||
|
<span>.{editingExtension.file_type}</span>
|
||||||
|
{editingExtension.homepage_url && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("extensions.homepage")}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={editingExtension.homepage_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline flex items-center gap-1 truncate"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{editingExtension.homepage_url}
|
||||||
|
</span>
|
||||||
|
<LuExternalLink className="w-3 h-3 shrink-0" />
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!editingExtension.version &&
|
||||||
|
!editingExtension.author &&
|
||||||
|
!editingExtension.description &&
|
||||||
|
!editingExtension.homepage_url && (
|
||||||
|
<span className="col-span-2 text-muted-foreground text-xs">
|
||||||
|
{t("extensions.noMetadata")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Re-upload */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("extensions.reupload")}</Label>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<RippleButton
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
document.getElementById("ext-edit-file-input")?.click()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LuUpload className="w-3 h-3 mr-1" />
|
||||||
|
{t("extensions.selectFile")}
|
||||||
|
</RippleButton>
|
||||||
|
<input
|
||||||
|
id="ext-edit-file-input"
|
||||||
|
type="file"
|
||||||
|
accept=".xpi,.crx,.zip"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleEditFileSelect}
|
||||||
|
/>
|
||||||
|
{pendingUpdateFile && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||||
|
{pendingUpdateFile.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* Re-upload */}
|
</ScrollArea>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t("extensions.reupload")}</Label>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<RippleButton
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
document.getElementById("ext-edit-file-input")?.click()
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<LuUpload className="w-3 h-3 mr-1" />
|
|
||||||
{t("extensions.selectFile")}
|
|
||||||
</RippleButton>
|
|
||||||
<input
|
|
||||||
id="ext-edit-file-input"
|
|
||||||
type="file"
|
|
||||||
accept=".xpi,.crx,.zip"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleEditFileSelect}
|
|
||||||
/>
|
|
||||||
{pendingUpdateFile && (
|
|
||||||
<span className="text-xs text-muted-foreground truncate max-w-[200px]">
|
|
||||||
{pendingUpdateFile.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export function GroupBadges({
|
|||||||
return (
|
return (
|
||||||
<div className="flex gap-2 mb-4">
|
<div className="flex gap-2 mb-4">
|
||||||
<div className="flex items-center gap-2 px-4.5 py-1.5 text-xs">
|
<div className="flex items-center gap-2 px-4.5 py-1.5 text-xs">
|
||||||
Loading groups...
|
{t("groups.loading")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ export function GroupManagementDialog({
|
|||||||
{/* Groups list */}
|
{/* Groups list */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t("common.loading")}
|
{t("common.buttons.loading")}
|
||||||
</div>
|
</div>
|
||||||
) : groups.length === 0 ? (
|
) : groups.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ const HomeHeader = ({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex gap-2 items-center h-[36px]"
|
className="flex gap-2 items-center h-[36px] border-foreground/20 hover:text-foreground"
|
||||||
>
|
>
|
||||||
<GoKebabHorizontal className="w-4 h-4" />
|
<GoKebabHorizontal className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -543,9 +543,9 @@ export function ImportProfileDialog({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{t("importProfile.importedAsPrefix")}{" "}
|
{t("importProfile.importedAs", {
|
||||||
<strong>{getBrowserDisplayName(currentMappedBrowser)}</strong>{" "}
|
browser: getBrowserDisplayName(currentMappedBrowser),
|
||||||
{t("importProfile.importedAsSuffix")}
|
})}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { BsCamera, BsMic } from "react-icons/bs";
|
import { BsCamera, BsMic } from "react-icons/bs";
|
||||||
import { LoadingButton } from "@/components/loading-button";
|
import { LoadingButton } from "@/components/loading-button";
|
||||||
@@ -21,7 +21,14 @@ interface PermissionDialogProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
permissionType: PermissionType;
|
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({
|
export function PermissionDialog({
|
||||||
@@ -32,6 +39,7 @@ export function PermissionDialog({
|
|||||||
}: PermissionDialogProps) {
|
}: PermissionDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isRequesting, setIsRequesting] = useState(false);
|
const [isRequesting, setIsRequesting] = useState(false);
|
||||||
|
const [isWaitingForGrant, setIsWaitingForGrant] = useState(false);
|
||||||
const [isMacOS, setIsMacOS] = useState(false);
|
const [isMacOS, setIsMacOS] = useState(false);
|
||||||
const {
|
const {
|
||||||
requestPermission,
|
requestPermission,
|
||||||
@@ -57,12 +65,68 @@ export function PermissionDialog({
|
|||||||
? isMicrophoneAccessGranted
|
? isMicrophoneAccessGranted
|
||||||
: isCameraAccessGranted;
|
: 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(() => {
|
useEffect(() => {
|
||||||
if (isCurrentPermissionGranted && isOpen) {
|
isCurrentPermissionGrantedRef.current = isCurrentPermissionGranted;
|
||||||
onPermissionGranted?.();
|
}, [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<PermissionType | null>(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<ReturnType<typeof setTimeout> | 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) => {
|
const getPermissionIcon = (type: PermissionType) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -95,11 +159,25 @@ export function PermissionDialog({
|
|||||||
setIsRequesting(true);
|
setIsRequesting(true);
|
||||||
try {
|
try {
|
||||||
await requestPermission(permissionType);
|
await requestPermission(permissionType);
|
||||||
showSuccessToast(
|
// The macOS permission poll runs every 5 s, so the new state can take
|
||||||
permissionType === "microphone"
|
// a moment to surface. Keep the grant button in its busy state for
|
||||||
? t("permissionDialog.requestSuccessMicrophone")
|
// that window so the user has clear feedback, and notify them if the
|
||||||
: t("permissionDialog.requestSuccessCamera"),
|
// 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) {
|
} catch (error) {
|
||||||
console.error("Failed to request permission:", error);
|
console.error("Failed to request permission:", error);
|
||||||
showErrorToast(t("permissionDialog.requestFailed"));
|
showErrorToast(t("permissionDialog.requestFailed"));
|
||||||
@@ -129,16 +207,6 @@ export function PermissionDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{isCurrentPermissionGranted && (
|
|
||||||
<div className="p-3 bg-success/10 rounded-lg">
|
|
||||||
<p className="text-sm text-success">
|
|
||||||
{permissionType === "microphone"
|
|
||||||
? t("permissionDialog.grantedMicrophone")
|
|
||||||
: t("permissionDialog.grantedCamera")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isCurrentPermissionGranted && (
|
{!isCurrentPermissionGranted && (
|
||||||
<div className="p-3 bg-warning/10 rounded-lg">
|
<div className="p-3 bg-warning/10 rounded-lg">
|
||||||
<p className="text-sm text-warning">
|
<p className="text-sm text-warning">
|
||||||
@@ -151,15 +219,17 @@ export function PermissionDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
<RippleButton variant="outline" onClick={onClose}>
|
<RippleButton
|
||||||
{isCurrentPermissionGranted
|
variant="outline"
|
||||||
? t("permissionDialog.doneButton")
|
onClick={onClose}
|
||||||
: t("permissionDialog.cancelButton")}
|
className="min-w-24"
|
||||||
|
>
|
||||||
|
{t("permissionDialog.cancelButton")}
|
||||||
</RippleButton>
|
</RippleButton>
|
||||||
|
|
||||||
{!isCurrentPermissionGranted && (
|
{!isCurrentPermissionGranted && (
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
isLoading={isRequesting}
|
isLoading={isRequesting || isWaitingForGrant}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleRequestPermission().catch((err: unknown) => {
|
handleRequestPermission().catch((err: unknown) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@@ -536,7 +536,11 @@ const TagsCell = React.memo<{
|
|||||||
onChange={(opts) => void handleChange(opts)}
|
onChange={(opts) => void handleChange(opts)}
|
||||||
creatable
|
creatable
|
||||||
selectFirstItem={false}
|
selectFirstItem={false}
|
||||||
placeholder={effectiveTags.length === 0 ? "Add tags" : ""}
|
placeholder={
|
||||||
|
effectiveTags.length === 0
|
||||||
|
? translate("profileTable.addTagsPlaceholder")
|
||||||
|
: ""
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-transparent border-0! focus-within:ring-0!",
|
"bg-transparent border-0! focus-within:ring-0!",
|
||||||
"[&_div:first-child]:border-0! [&_div:first-child]:ring-0! [&_div:first-child]:focus-within:ring-0!",
|
"[&_div:first-child]:border-0! [&_div:first-child]:ring-0! [&_div:first-child]:focus-within:ring-0!",
|
||||||
@@ -903,6 +907,13 @@ export function ProfilesDataTable({
|
|||||||
}
|
}
|
||||||
setRowSelection(newSelection);
|
setRowSelection(newSelection);
|
||||||
prevSelectedProfilesRef.current = selectedProfiles;
|
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]);
|
}, [selectedProfiles]);
|
||||||
|
|
||||||
@@ -1846,6 +1857,7 @@ export function ProfilesDataTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
|
size: 100,
|
||||||
cell: ({ row, table }) => {
|
cell: ({ row, table }) => {
|
||||||
const meta = table.options.meta as TableMeta;
|
const meta = table.options.meta as TableMeta;
|
||||||
const profile = row.original;
|
const profile = row.original;
|
||||||
@@ -1964,7 +1976,7 @@ export function ProfilesDataTable({
|
|||||||
size="sm"
|
size="sm"
|
||||||
disabled={!canLaunch || isLaunching || isStopping}
|
disabled={!canLaunch || isLaunching || isStopping}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-[70px] h-7",
|
"min-w-[80px] h-7 px-3",
|
||||||
!canLaunch && "opacity-50 cursor-not-allowed",
|
!canLaunch && "opacity-50 cursor-not-allowed",
|
||||||
canLaunch && "cursor-pointer",
|
canLaunch && "cursor-pointer",
|
||||||
isFollower && "border-accent",
|
isFollower && "border-accent",
|
||||||
@@ -1980,9 +1992,9 @@ export function ProfilesDataTable({
|
|||||||
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
|
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
) : isRunning ? (
|
) : isRunning ? (
|
||||||
"Stop"
|
meta.t("profiles.actions.stop")
|
||||||
) : (
|
) : (
|
||||||
"Launch"
|
meta.t("profiles.actions.launch")
|
||||||
)}
|
)}
|
||||||
</RippleButton>
|
</RippleButton>
|
||||||
</span>
|
</span>
|
||||||
@@ -1999,7 +2011,9 @@ export function ProfilesDataTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
header: ({ column }) => {
|
size: 130,
|
||||||
|
header: ({ column, table }) => {
|
||||||
|
const meta = table.options.meta as TableMeta;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -2008,7 +2022,7 @@ export function ProfilesDataTable({
|
|||||||
}}
|
}}
|
||||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||||
>
|
>
|
||||||
Name
|
{meta.t("common.labels.name")}
|
||||||
{column.getIsSorted() === "asc" ? (
|
{column.getIsSorted() === "asc" ? (
|
||||||
<LuChevronUp className="ml-2 w-4 h-4" />
|
<LuChevronUp className="ml-2 w-4 h-4" />
|
||||||
) : column.getIsSorted() === "desc" ? (
|
) : column.getIsSorted() === "desc" ? (
|
||||||
@@ -2137,7 +2151,11 @@ export function ProfilesDataTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tags",
|
id: "tags",
|
||||||
header: "Tags",
|
size: 110,
|
||||||
|
header: ({ table }) => {
|
||||||
|
const meta = table.options.meta as TableMeta;
|
||||||
|
return meta.t("profileTable.tagsHeader");
|
||||||
|
},
|
||||||
cell: ({ row, table }) => {
|
cell: ({ row, table }) => {
|
||||||
const meta = table.options.meta as TableMeta;
|
const meta = table.options.meta as TableMeta;
|
||||||
const profile = row.original;
|
const profile = row.original;
|
||||||
@@ -2166,7 +2184,11 @@ export function ProfilesDataTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "note",
|
id: "note",
|
||||||
header: "Note",
|
size: 110,
|
||||||
|
header: ({ table }) => {
|
||||||
|
const meta = table.options.meta as TableMeta;
|
||||||
|
return meta.t("profileTable.noteHeader");
|
||||||
|
},
|
||||||
cell: ({ row, table }) => {
|
cell: ({ row, table }) => {
|
||||||
const meta = table.options.meta as TableMeta;
|
const meta = table.options.meta as TableMeta;
|
||||||
const profile = row.original;
|
const profile = row.original;
|
||||||
@@ -2193,7 +2215,11 @@ export function ProfilesDataTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "proxy",
|
id: "proxy",
|
||||||
header: "Proxy / VPN",
|
size: 130,
|
||||||
|
header: ({ table }) => {
|
||||||
|
const meta = table.options.meta as TableMeta;
|
||||||
|
return meta.t("profiles.table.proxy");
|
||||||
|
},
|
||||||
cell: ({ row, table }) => {
|
cell: ({ row, table }) => {
|
||||||
const meta = table.options.meta as TableMeta;
|
const meta = table.options.meta as TableMeta;
|
||||||
const profile = row.original;
|
const profile = row.original;
|
||||||
@@ -2231,7 +2257,7 @@ export function ProfilesDataTable({
|
|||||||
? effectiveVpn.name
|
? effectiveVpn.name
|
||||||
: effectiveProxy
|
: effectiveProxy
|
||||||
? effectiveProxy.name
|
? effectiveProxy.name
|
||||||
: "Not Selected";
|
: meta.t("profiles.table.notSelected");
|
||||||
const vpnBadge = effectiveVpn ? "WG" : null;
|
const vpnBadge = effectiveVpn ? "WG" : null;
|
||||||
const tooltipText = hasAssignment ? displayName : null;
|
const tooltipText = hasAssignment ? displayName : null;
|
||||||
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
|
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
|
||||||
@@ -2372,7 +2398,7 @@ export function ProfilesDataTable({
|
|||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
{meta.vpnConfigs.length > 0 && (
|
{meta.vpnConfigs.length > 0 && (
|
||||||
<CommandGroup heading="VPNs">
|
<CommandGroup heading={t("profileTable.vpnsHeading")}>
|
||||||
{meta.vpnConfigs.map((vpn) => (
|
{meta.vpnConfigs.map((vpn) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={vpn.id}
|
key={vpn.id}
|
||||||
@@ -2405,7 +2431,9 @@ export function ProfilesDataTable({
|
|||||||
)}
|
)}
|
||||||
{meta.canCreateLocationProxy &&
|
{meta.canCreateLocationProxy &&
|
||||||
meta.countries.length > 0 && (
|
meta.countries.length > 0 && (
|
||||||
<CommandGroup heading="Create by country">
|
<CommandGroup
|
||||||
|
heading={t("profileTable.createByCountryHeading")}
|
||||||
|
>
|
||||||
{meta.countries
|
{meta.countries
|
||||||
.filter(
|
.filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
@@ -2569,7 +2597,7 @@ export function ProfilesDataTable({
|
|||||||
platform === "macos" ? "h-[340px]" : "h-[280px]",
|
platform === "macos" ? "h-[340px]" : "h-[280px]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Table className="overflow-visible">
|
<Table className="overflow-visible table-fixed">
|
||||||
<TableHeader className="overflow-visible">
|
<TableHeader className="overflow-visible">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id} className="overflow-visible">
|
<TableRow key={headerGroup.id} className="overflow-visible">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { LoadingButton } from "@/components/loading-button";
|
import { LoadingButton } from "@/components/loading-button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -50,7 +50,15 @@ export function ProfileSelectorDialog({
|
|||||||
}: ProfileSelectorDialogProps) {
|
}: ProfileSelectorDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Use the centralized profile events hook
|
// Use the centralized profile events hook
|
||||||
const { profiles, runningProfiles: hookRunningProfiles } = useProfileEvents();
|
const { profiles: rawProfiles, runningProfiles: hookRunningProfiles } =
|
||||||
|
useProfileEvents();
|
||||||
|
const profiles = useMemo(
|
||||||
|
() =>
|
||||||
|
[...rawProfiles].sort((a, b) =>
|
||||||
|
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
|
||||||
|
),
|
||||||
|
[rawProfiles],
|
||||||
|
);
|
||||||
|
|
||||||
// Use external runningProfiles if provided, otherwise use hook's runningProfiles
|
// Use external runningProfiles if provided, otherwise use hook's runningProfiles
|
||||||
const runningProfiles = externalRunningProfiles ?? hookRunningProfiles;
|
const runningProfiles = externalRunningProfiles ?? hookRunningProfiles;
|
||||||
@@ -148,11 +156,7 @@ export function ProfileSelectorDialog({
|
|||||||
if (runningAvailableProfile) {
|
if (runningAvailableProfile) {
|
||||||
setSelectedProfile(runningAvailableProfile.name);
|
setSelectedProfile(runningAvailableProfile.name);
|
||||||
} else {
|
} else {
|
||||||
// Sort profiles by name and select first
|
setSelectedProfile(profiles[0].name);
|
||||||
const sortedProfiles = [...profiles].sort((a, b) =>
|
|
||||||
a.name.localeCompare(b.name),
|
|
||||||
);
|
|
||||||
setSelectedProfile(sortedProfiles[0].name);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isOpen, profiles, selectedProfile, runningProfiles]);
|
}, [isOpen, profiles, selectedProfile, runningProfiles]);
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export function ProfileSyncDialog({
|
|||||||
}, [profile, hasConfig, onSyncConfigOpen, onClose, t]);
|
}, [profile, hasConfig, onSyncConfigOpen, onClose, t]);
|
||||||
|
|
||||||
const formatLastSync = (timestamp?: number) => {
|
const formatLastSync = (timestamp?: number) => {
|
||||||
if (!timestamp) return t("common.labels.never", "Never");
|
if (!timestamp) return t("common.labels.never");
|
||||||
const date = new Date(timestamp * 1000);
|
const date = new Date(timestamp * 1000);
|
||||||
return date.toLocaleString();
|
return date.toLocaleString();
|
||||||
};
|
};
|
||||||
@@ -177,7 +177,7 @@ export function ProfileSyncDialog({
|
|||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("sync.mode.title", "Profile Sync")}</DialogTitle>
|
<DialogTitle>{t("sync.mode.title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t("sync.mode.description", {
|
{t("sync.mode.description", {
|
||||||
name: profile.name,
|
name: profile.name,
|
||||||
@@ -194,9 +194,7 @@ export function ProfileSyncDialog({
|
|||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
{!hasConfig && (
|
{!hasConfig && (
|
||||||
<div className="p-3 text-sm rounded-md bg-muted">
|
<div className="p-3 text-sm rounded-md bg-muted">
|
||||||
<p className="mb-2">
|
<p className="mb-2">{t("sync.mode.notConfigured")}</p>
|
||||||
{t("sync.mode.notConfigured", "Sync service not configured.")}
|
|
||||||
</p>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -205,7 +203,7 @@ export function ProfileSyncDialog({
|
|||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("sync.mode.configureService", "Configure Sync Service")}
|
{t("sync.mode.configureService")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -222,13 +220,10 @@ export function ProfileSyncDialog({
|
|||||||
<RadioGroupItem value="Disabled" id="sync-disabled" />
|
<RadioGroupItem value="Disabled" id="sync-disabled" />
|
||||||
<Label htmlFor="sync-disabled" className="cursor-pointer">
|
<Label htmlFor="sync-disabled" className="cursor-pointer">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{t("sync.mode.disabled", "Disabled")}
|
{t("sync.mode.disabled")}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t(
|
{t("sync.mode.disabledDescription")}
|
||||||
"sync.mode.disabledDescription",
|
|
||||||
"No sync for this profile",
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
@@ -237,13 +232,10 @@ export function ProfileSyncDialog({
|
|||||||
<RadioGroupItem value="Regular" id="sync-regular" />
|
<RadioGroupItem value="Regular" id="sync-regular" />
|
||||||
<Label htmlFor="sync-regular" className="cursor-pointer">
|
<Label htmlFor="sync-regular" className="cursor-pointer">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{t("sync.mode.regular", "Regular Sync")}
|
{t("sync.mode.regular")}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t(
|
{t("sync.mode.regularDescription")}
|
||||||
"sync.mode.regularDescription",
|
|
||||||
"Fast sync, unencrypted",
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,18 +255,12 @@ export function ProfileSyncDialog({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{t("sync.mode.encrypted", "E2E Encrypted Sync")}
|
{t("sync.mode.encrypted")}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{canUseEncryption
|
{canUseEncryption
|
||||||
? t(
|
? t("sync.mode.encryptedDescription")
|
||||||
"sync.mode.encryptedDescription",
|
: t("settings.encryption.requiresProOrOwner")}
|
||||||
"Encrypted before upload. Server never sees plaintext data.",
|
|
||||||
)
|
|
||||||
: t(
|
|
||||||
"settings.encryption.requiresProOrOwner",
|
|
||||||
"Profile encryption is available for Pro users and team owners.",
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,15 +270,12 @@ export function ProfileSyncDialog({
|
|||||||
!hasE2ePassword &&
|
!hasE2ePassword &&
|
||||||
userChangedMode && (
|
userChangedMode && (
|
||||||
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
|
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
|
||||||
{t(
|
{t("sync.mode.noPasswordWarning")}
|
||||||
"sync.mode.noPasswordWarning",
|
|
||||||
"E2E password not set. Please set a password in Settings.",
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("sync.mode.lastSynced", "Last Synced")}</Label>
|
<Label>{t("sync.mode.lastSynced")}</Label>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{formatLastSync(profile.last_sync)}
|
{formatLastSync(profile.last_sync)}
|
||||||
@@ -319,7 +302,7 @@ export function ProfileSyncDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
{hasConfig && isSyncEnabled(profile) && (
|
{hasConfig && isSyncEnabled(profile) && (
|
||||||
<LoadingButton onClick={handleSyncNow} isLoading={isSyncing}>
|
<LoadingButton onClick={handleSyncNow} isLoading={isSyncing}>
|
||||||
{t("sync.mode.syncNow", "Sync Now")}
|
{t("sync.mode.syncNow")}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
)}
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ export function ProxyManagementDialog({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
<DialogContent className="max-w-[min(95vw,1600px)] max-h-[90vh] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
|
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -411,7 +411,7 @@ export function ProxyManagementDialog({
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="proxies">
|
<TabsContent value="proxies" className="mt-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -460,196 +460,188 @@ export function ProxyManagementDialog({
|
|||||||
{t("proxies.management.noneCreated")}
|
{t("proxies.management.noneCreated")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="border rounded-md">
|
<div className="border rounded-md max-h-[240px] overflow-auto">
|
||||||
<ScrollArea className="h-[240px]">
|
<Table className="min-w-max">
|
||||||
<Table>
|
<TableHeader>
|
||||||
<TableHeader>
|
<TableRow>
|
||||||
<TableRow>
|
<TableHead>{t("common.labels.name")}</TableHead>
|
||||||
<TableHead>{t("common.labels.name")}</TableHead>
|
<TableHead className="whitespace-nowrap w-px">
|
||||||
<TableHead className="w-20">
|
{t("proxies.management.usage")}
|
||||||
{t("proxies.management.usage")}
|
</TableHead>
|
||||||
</TableHead>
|
<TableHead className="whitespace-nowrap w-px">
|
||||||
<TableHead className="w-24">
|
{t("proxies.management.syncCol")}
|
||||||
{t("proxies.management.syncCol")}
|
</TableHead>
|
||||||
</TableHead>
|
<TableHead className="whitespace-nowrap w-px">
|
||||||
<TableHead className="w-24">
|
{t("common.labels.actions")}
|
||||||
{t("common.labels.actions")}
|
</TableHead>
|
||||||
</TableHead>
|
</TableRow>
|
||||||
</TableRow>
|
</TableHeader>
|
||||||
</TableHeader>
|
<TableBody>
|
||||||
<TableBody>
|
{storedProxies.map((proxy) => {
|
||||||
{storedProxies.map((proxy) => {
|
const syncDot = getSyncStatusDot(
|
||||||
const syncDot = getSyncStatusDot(
|
proxy,
|
||||||
proxy,
|
proxySyncStatus[proxy.id],
|
||||||
proxySyncStatus[proxy.id],
|
t,
|
||||||
t,
|
proxySyncErrors[proxy.id],
|
||||||
proxySyncErrors[proxy.id],
|
);
|
||||||
);
|
return (
|
||||||
return (
|
<TableRow key={proxy.id}>
|
||||||
<TableRow key={proxy.id}>
|
<TableCell className="font-medium">
|
||||||
<TableCell className="font-medium">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div
|
|
||||||
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
|
||||||
syncDot.animate
|
|
||||||
? "animate-pulse"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{syncDot.tooltip}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
{proxy.name}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{proxyUsage[proxy.id] ?? 0}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center">
|
<div
|
||||||
<Checkbox
|
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
||||||
checked={proxy.sync_enabled}
|
syncDot.animate
|
||||||
onCheckedChange={() =>
|
? "animate-pulse"
|
||||||
void handleToggleSync(proxy)
|
: ""
|
||||||
}
|
}`}
|
||||||
disabled={
|
/>
|
||||||
isTogglingSync[proxy.id] ||
|
|
||||||
proxyInUse[proxy.id]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{proxyInUse[proxy.id] ? (
|
<p>{syncDot.tooltip}</p>
|
||||||
<p>
|
|
||||||
{t(
|
|
||||||
"proxies.management.syncCannotDisable",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p>
|
|
||||||
{proxy.sync_enabled
|
|
||||||
? t(
|
|
||||||
"proxies.management.disableSync",
|
|
||||||
)
|
|
||||||
: t(
|
|
||||||
"proxies.management.enableSync",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TableCell>
|
{proxy.name}
|
||||||
<TableCell>
|
</div>
|
||||||
<div className="flex gap-1">
|
</TableCell>
|
||||||
<ProxyCheckButton
|
<TableCell>
|
||||||
proxy={proxy}
|
<Badge variant="secondary">
|
||||||
profileId={proxy.id}
|
{proxyUsage[proxy.id] ?? 0}
|
||||||
checkingProfileId={checkingProxyId}
|
</Badge>
|
||||||
cachedResult={
|
</TableCell>
|
||||||
proxyCheckResults[proxy.id]
|
<TableCell>
|
||||||
}
|
<Tooltip>
|
||||||
setCheckingProfileId={
|
<TooltipTrigger asChild>
|
||||||
setCheckingProxyId
|
<div className="flex items-center">
|
||||||
}
|
<Checkbox
|
||||||
onCheckComplete={(result) => {
|
checked={proxy.sync_enabled}
|
||||||
setProxyCheckResults((prev) => ({
|
onCheckedChange={() =>
|
||||||
...prev,
|
void handleToggleSync(proxy)
|
||||||
[proxy.id]: result,
|
}
|
||||||
}));
|
disabled={
|
||||||
}}
|
isTogglingSync[proxy.id] ||
|
||||||
onCheckFailed={(result) => {
|
proxyInUse[proxy.id]
|
||||||
setProxyCheckResults((prev) => ({
|
}
|
||||||
...prev,
|
/>
|
||||||
[proxy.id]: result,
|
</div>
|
||||||
}));
|
</TooltipTrigger>
|
||||||
}}
|
<TooltipContent>
|
||||||
/>
|
{proxyInUse[proxy.id] ? (
|
||||||
<Tooltip>
|
<p>
|
||||||
<TooltipTrigger asChild>
|
{t(
|
||||||
|
"proxies.management.syncCannotDisable",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
{proxy.sync_enabled
|
||||||
|
? t(
|
||||||
|
"proxies.management.disableSync",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"proxies.management.enableSync",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<ProxyCheckButton
|
||||||
|
proxy={proxy}
|
||||||
|
profileId={proxy.id}
|
||||||
|
checkingProfileId={checkingProxyId}
|
||||||
|
cachedResult={proxyCheckResults[proxy.id]}
|
||||||
|
setCheckingProfileId={setCheckingProxyId}
|
||||||
|
onCheckComplete={(result) => {
|
||||||
|
setProxyCheckResults((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[proxy.id]: result,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
onCheckFailed={(result) => {
|
||||||
|
setProxyCheckResults((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[proxy.id]: result,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
handleEditProxy(proxy);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuPencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
{t("proxies.management.editProxy")}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleEditProxy(proxy);
|
handleDeleteProxy(proxy);
|
||||||
}}
|
}}
|
||||||
|
disabled={
|
||||||
|
(proxyUsage[proxy.id] ?? 0) > 0
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<LuPencil className="w-4 h-4" />
|
<LuTrash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</span>
|
||||||
<TooltipContent>
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
|
||||||
<p>
|
<p>
|
||||||
{t("proxies.management.editProxy")}
|
{(proxyUsage[proxy.id] ?? 0) === 1
|
||||||
|
? t(
|
||||||
|
"proxies.management.cannotDelete_one",
|
||||||
|
{
|
||||||
|
count: proxyUsage[proxy.id],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"proxies.management.cannotDelete_other",
|
||||||
|
{
|
||||||
|
count: proxyUsage[proxy.id],
|
||||||
|
},
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
) : (
|
||||||
</Tooltip>
|
<p>
|
||||||
<Tooltip>
|
{t(
|
||||||
<TooltipTrigger asChild>
|
"proxies.management.deleteProxy",
|
||||||
<span>
|
)}
|
||||||
<Button
|
</p>
|
||||||
variant="ghost"
|
)}
|
||||||
size="sm"
|
</TooltipContent>
|
||||||
onClick={() => {
|
</Tooltip>
|
||||||
handleDeleteProxy(proxy);
|
</div>
|
||||||
}}
|
</TableCell>
|
||||||
disabled={
|
</TableRow>
|
||||||
(proxyUsage[proxy.id] ?? 0) > 0
|
);
|
||||||
}
|
})}
|
||||||
>
|
</TableBody>
|
||||||
<LuTrash2 className="w-4 h-4" />
|
</Table>
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
|
|
||||||
<p>
|
|
||||||
{(proxyUsage[proxy.id] ?? 0) === 1
|
|
||||||
? t(
|
|
||||||
"proxies.management.cannotDelete_one",
|
|
||||||
{
|
|
||||||
count:
|
|
||||||
proxyUsage[proxy.id],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: t(
|
|
||||||
"proxies.management.cannotDelete_other",
|
|
||||||
{
|
|
||||||
count:
|
|
||||||
proxyUsage[proxy.id],
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p>
|
|
||||||
{t(
|
|
||||||
"proxies.management.deleteProxy",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="vpns">
|
<TabsContent value="vpns" className="mt-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -684,169 +676,167 @@ export function ProxyManagementDialog({
|
|||||||
{t("vpns.management.noneCreated")}
|
{t("vpns.management.noneCreated")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="border rounded-md">
|
<div className="border rounded-md max-h-[240px] overflow-auto">
|
||||||
<ScrollArea className="h-[240px]">
|
<Table className="min-w-max">
|
||||||
<Table>
|
<TableHeader>
|
||||||
<TableHeader>
|
<TableRow>
|
||||||
<TableRow>
|
<TableHead>{t("common.labels.name")}</TableHead>
|
||||||
<TableHead>{t("common.labels.name")}</TableHead>
|
<TableHead className="whitespace-nowrap w-px">
|
||||||
<TableHead className="w-16">
|
{t("common.labels.type")}
|
||||||
{t("common.labels.type")}
|
</TableHead>
|
||||||
</TableHead>
|
<TableHead className="whitespace-nowrap w-px">
|
||||||
<TableHead className="w-20">
|
{t("proxies.management.usage")}
|
||||||
{t("proxies.management.usage")}
|
</TableHead>
|
||||||
</TableHead>
|
<TableHead className="whitespace-nowrap w-px">
|
||||||
<TableHead className="w-24">
|
{t("proxies.management.syncCol")}
|
||||||
{t("proxies.management.syncCol")}
|
</TableHead>
|
||||||
</TableHead>
|
<TableHead className="whitespace-nowrap w-px">
|
||||||
<TableHead className="w-24">
|
{t("common.labels.actions")}
|
||||||
{t("common.labels.actions")}
|
</TableHead>
|
||||||
</TableHead>
|
</TableRow>
|
||||||
</TableRow>
|
</TableHeader>
|
||||||
</TableHeader>
|
<TableBody>
|
||||||
<TableBody>
|
{vpnConfigs.map((vpn) => {
|
||||||
{vpnConfigs.map((vpn) => {
|
const syncDot = getSyncStatusDot(
|
||||||
const syncDot = getSyncStatusDot(
|
vpn,
|
||||||
vpn,
|
vpnSyncStatus[vpn.id],
|
||||||
vpnSyncStatus[vpn.id],
|
t,
|
||||||
t,
|
vpnSyncErrors[vpn.id],
|
||||||
vpnSyncErrors[vpn.id],
|
);
|
||||||
);
|
return (
|
||||||
return (
|
<TableRow key={vpn.id}>
|
||||||
<TableRow key={vpn.id}>
|
<TableCell className="font-medium">
|
||||||
<TableCell className="font-medium">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div
|
|
||||||
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
|
||||||
syncDot.animate
|
|
||||||
? "animate-pulse"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{syncDot.tooltip}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
{vpn.name}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline">WG</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{vpnUsage[vpn.id] ?? 0}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center">
|
<div
|
||||||
<Checkbox
|
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
||||||
checked={vpn.sync_enabled}
|
syncDot.animate
|
||||||
onCheckedChange={() =>
|
? "animate-pulse"
|
||||||
void handleToggleVpnSync(vpn)
|
: ""
|
||||||
}
|
}`}
|
||||||
disabled={
|
/>
|
||||||
isTogglingVpnSync[vpn.id] ||
|
|
||||||
vpnInUse[vpn.id]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{vpnInUse[vpn.id] ? (
|
<p>{syncDot.tooltip}</p>
|
||||||
<p>
|
|
||||||
{t(
|
|
||||||
"vpns.management.syncCannotDisable",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p>
|
|
||||||
{vpn.sync_enabled
|
|
||||||
? t(
|
|
||||||
"proxies.management.disableSync",
|
|
||||||
)
|
|
||||||
: t(
|
|
||||||
"proxies.management.enableSync",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TableCell>
|
{vpn.name}
|
||||||
<TableCell>
|
</div>
|
||||||
<div className="flex gap-1">
|
</TableCell>
|
||||||
<VpnCheckButton
|
<TableCell>
|
||||||
vpnId={vpn.id}
|
<Badge variant="outline">WG</Badge>
|
||||||
vpnName={vpn.name}
|
</TableCell>
|
||||||
checkingVpnId={checkingVpnId}
|
<TableCell>
|
||||||
setCheckingVpnId={setCheckingVpnId}
|
<Badge variant="secondary">
|
||||||
/>
|
{vpnUsage[vpn.id] ?? 0}
|
||||||
<Tooltip>
|
</Badge>
|
||||||
<TooltipTrigger asChild>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={vpn.sync_enabled}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
void handleToggleVpnSync(vpn)
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
isTogglingVpnSync[vpn.id] ||
|
||||||
|
vpnInUse[vpn.id]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{vpnInUse[vpn.id] ? (
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
"vpns.management.syncCannotDisable",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
{vpn.sync_enabled
|
||||||
|
? t(
|
||||||
|
"proxies.management.disableSync",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"proxies.management.enableSync",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<VpnCheckButton
|
||||||
|
vpnId={vpn.id}
|
||||||
|
vpnName={vpn.name}
|
||||||
|
checkingVpnId={checkingVpnId}
|
||||||
|
setCheckingVpnId={setCheckingVpnId}
|
||||||
|
/>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
handleEditVpn(vpn);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuPencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("vpns.management.editVpn")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleEditVpn(vpn);
|
handleDeleteVpn(vpn);
|
||||||
}}
|
}}
|
||||||
|
disabled={
|
||||||
|
(vpnUsage[vpn.id] ?? 0) > 0
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<LuPencil className="w-4 h-4" />
|
<LuTrash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</span>
|
||||||
<TooltipContent>
|
</TooltipTrigger>
|
||||||
<p>{t("vpns.management.editVpn")}</p>
|
<TooltipContent>
|
||||||
</TooltipContent>
|
{(vpnUsage[vpn.id] ?? 0) > 0 ? (
|
||||||
</Tooltip>
|
<p>
|
||||||
<Tooltip>
|
{(vpnUsage[vpn.id] ?? 0) === 1
|
||||||
<TooltipTrigger asChild>
|
? t(
|
||||||
<span>
|
"vpns.management.cannotDelete_one",
|
||||||
<Button
|
{ count: vpnUsage[vpn.id] },
|
||||||
variant="ghost"
|
)
|
||||||
size="sm"
|
: t(
|
||||||
onClick={() => {
|
"vpns.management.cannotDelete_other",
|
||||||
handleDeleteVpn(vpn);
|
{ count: vpnUsage[vpn.id] },
|
||||||
}}
|
)}
|
||||||
disabled={
|
</p>
|
||||||
(vpnUsage[vpn.id] ?? 0) > 0
|
) : (
|
||||||
}
|
<p>
|
||||||
>
|
{t("vpns.management.deleteVpn")}
|
||||||
<LuTrash2 className="w-4 h-4" />
|
</p>
|
||||||
</Button>
|
)}
|
||||||
</span>
|
</TooltipContent>
|
||||||
</TooltipTrigger>
|
</Tooltip>
|
||||||
<TooltipContent>
|
</div>
|
||||||
{(vpnUsage[vpn.id] ?? 0) > 0 ? (
|
</TableCell>
|
||||||
<p>
|
</TableRow>
|
||||||
{(vpnUsage[vpn.id] ?? 0) === 1
|
);
|
||||||
? t(
|
})}
|
||||||
"vpns.management.cannotDelete_one",
|
</TableBody>
|
||||||
{ count: vpnUsage[vpn.id] },
|
</Table>
|
||||||
)
|
|
||||||
: t(
|
|
||||||
"vpns.management.cannotDelete_other",
|
|
||||||
{ count: vpnUsage[vpn.id] },
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p>
|
|
||||||
{t("vpns.management.deleteVpn")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -408,7 +408,12 @@ export function SettingsDialog({
|
|||||||
// Update settings with any generated tokens
|
// Update settings with any generated tokens
|
||||||
setSettings(savedSettings);
|
setSettings(savedSettings);
|
||||||
settingsToSave = 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
|
// Apply or clear custom variables only on Save
|
||||||
if (settings.theme === "custom") {
|
if (settings.theme === "custom") {
|
||||||
@@ -539,7 +544,7 @@ export function SettingsDialog({
|
|||||||
checkDefaultBrowserStatus().catch((err: unknown) => {
|
checkDefaultBrowserStatus().catch((err: unknown) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
});
|
});
|
||||||
}, 500); // Check every 500ms
|
}, 2000);
|
||||||
|
|
||||||
// Cleanup interval on component unmount or dialog close
|
// Cleanup interval on component unmount or dialog close
|
||||||
return () => {
|
return () => {
|
||||||
@@ -811,7 +816,7 @@ export function SettingsDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Choose your preferred language for the application interface.
|
{t("settings.language.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -820,10 +825,12 @@ export function SettingsDialog({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<Label className="text-base font-medium">
|
<Label className="text-base font-medium">
|
||||||
Default Browser
|
{t("settings.defaultBrowser.title")}
|
||||||
</Label>
|
</Label>
|
||||||
<Badge variant={isDefaultBrowser ? "default" : "secondary"}>
|
<Badge variant={isDefaultBrowser ? "default" : "secondary"}>
|
||||||
{isDefaultBrowser ? "Active" : "Inactive"}
|
{isDefaultBrowser
|
||||||
|
? t("common.status.active")
|
||||||
|
: t("common.status.inactive")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -839,13 +846,12 @@ export function SettingsDialog({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{isDefaultBrowser
|
{isDefaultBrowser
|
||||||
? "Already Default Browser"
|
? t("settings.defaultBrowser.alreadyDefault")
|
||||||
: "Set as Default Browser"}
|
: t("settings.defaultBrowser.setAsDefault")}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
When set as default, Donut Browser will handle web links and
|
{t("settings.defaultBrowser.description")}
|
||||||
allow you to choose which profile to use.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -854,12 +860,12 @@ export function SettingsDialog({
|
|||||||
{isMacOS && (
|
{isMacOS && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Label className="text-base font-medium">
|
<Label className="text-base font-medium">
|
||||||
System Permissions
|
{t("settings.permissions.title")}
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
{isLoadingPermissions ? (
|
{isLoadingPermissions ? (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Loading permissions...
|
{t("settings.permissions.loading")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -928,7 +934,7 @@ export function SettingsDialog({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={onIntegrationsOpen}
|
onClick={onIntegrationsOpen}
|
||||||
>
|
>
|
||||||
Open Integrations Settings
|
{t("integrations.openSettings")}
|
||||||
</RippleButton>
|
</RippleButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -952,33 +958,24 @@ export function SettingsDialog({
|
|||||||
{/* Sync Encryption Section */}
|
{/* Sync Encryption Section */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Label className="text-base font-medium">
|
<Label className="text-base font-medium">
|
||||||
{t("settings.encryption.title", "Sync Encryption")}
|
{t("settings.encryption.title")}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t("settings.encryption.description")}
|
||||||
"settings.encryption.description",
|
|
||||||
"Set a password to enable E2E encrypted sync. If you lose this password, encrypted profiles cannot be recovered.",
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!canUseEncryption ? (
|
{!canUseEncryption ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t(
|
{t("settings.encryption.requiresProOrOwner")}
|
||||||
"settings.encryption.requiresProOrOwner",
|
|
||||||
"Profile encryption is available for Pro users and team owners.",
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
) : hasE2ePassword ? (
|
) : hasE2ePassword ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="default">
|
<Badge variant="default">
|
||||||
{t("settings.encryption.passwordSet", "Active")}
|
{t("settings.encryption.passwordSet")}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{t(
|
{t("settings.encryption.passwordSetDescription")}
|
||||||
"settings.encryption.passwordSetDescription",
|
|
||||||
"E2E encryption password is set",
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -992,10 +989,7 @@ export function SettingsDialog({
|
|||||||
setE2eError("");
|
setE2eError("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t(
|
{t("settings.encryption.changePassword")}
|
||||||
"settings.encryption.changePassword",
|
|
||||||
"Change Password",
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -1004,21 +998,13 @@ export function SettingsDialog({
|
|||||||
try {
|
try {
|
||||||
await invoke("delete_e2e_password");
|
await invoke("delete_e2e_password");
|
||||||
setHasE2ePassword(false);
|
setHasE2ePassword(false);
|
||||||
showSuccessToast(
|
showSuccessToast(t("settings.encryption.removed"));
|
||||||
t(
|
|
||||||
"settings.encryption.removed",
|
|
||||||
"Encryption password removed",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showErrorToast(String(error));
|
showErrorToast(String(error));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t(
|
{t("settings.encryption.removePassword")}
|
||||||
"settings.encryption.removePassword",
|
|
||||||
"Remove Password",
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1026,10 +1012,7 @@ export function SettingsDialog({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={t(
|
placeholder={t("settings.encryption.passwordPlaceholder")}
|
||||||
"settings.encryption.passwordPlaceholder",
|
|
||||||
"Password (min 8 characters)",
|
|
||||||
)}
|
|
||||||
value={e2ePassword}
|
value={e2ePassword}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setE2ePassword(e.target.value);
|
setE2ePassword(e.target.value);
|
||||||
@@ -1038,10 +1021,7 @@ export function SettingsDialog({
|
|||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={t(
|
placeholder={t("settings.encryption.confirmPlaceholder")}
|
||||||
"settings.encryption.confirmPlaceholder",
|
|
||||||
"Confirm password",
|
|
||||||
)}
|
|
||||||
value={e2ePasswordConfirm}
|
value={e2ePasswordConfirm}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setE2ePasswordConfirm(e.target.value);
|
setE2ePasswordConfirm(e.target.value);
|
||||||
@@ -1057,21 +1037,11 @@ export function SettingsDialog({
|
|||||||
isLoading={isSavingE2e}
|
isLoading={isSavingE2e}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (e2ePassword.length < 8) {
|
if (e2ePassword.length < 8) {
|
||||||
setE2eError(
|
setE2eError(t("settings.encryption.passwordTooShort"));
|
||||||
t(
|
|
||||||
"settings.encryption.passwordTooShort",
|
|
||||||
"Password must be at least 8 characters",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e2ePassword !== e2ePasswordConfirm) {
|
if (e2ePassword !== e2ePasswordConfirm) {
|
||||||
setE2eError(
|
setE2eError(t("settings.encryption.passwordMismatch"));
|
||||||
t(
|
|
||||||
"settings.encryption.passwordMismatch",
|
|
||||||
"Passwords do not match",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsSavingE2e(true);
|
setIsSavingE2e(true);
|
||||||
@@ -1083,10 +1053,7 @@ export function SettingsDialog({
|
|||||||
setE2ePassword("");
|
setE2ePassword("");
|
||||||
setE2ePasswordConfirm("");
|
setE2ePasswordConfirm("");
|
||||||
showSuccessToast(
|
showSuccessToast(
|
||||||
t(
|
t("settings.encryption.passwordSaved"),
|
||||||
"settings.encryption.passwordSaved",
|
|
||||||
"Encryption password set",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showErrorToast(String(error));
|
showErrorToast(String(error));
|
||||||
@@ -1095,7 +1062,7 @@ export function SettingsDialog({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("settings.encryption.setPassword", "Set Password")}
|
{t("settings.encryption.setPassword")}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1172,13 +1139,11 @@ export function SettingsDialog({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
Clear All Version Cache
|
{t("settings.advanced.clearCache")}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Clear all cached browser version data and refresh all browser
|
{t("settings.advanced.clearCacheDescription")}
|
||||||
versions from their sources. This will force a fresh download of
|
|
||||||
version information for all browsers.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1194,7 +1159,7 @@ export function SettingsDialog({
|
|||||||
|
|
||||||
<DialogFooter className="shrink-0">
|
<DialogFooter className="shrink-0">
|
||||||
<RippleButton variant="outline" onClick={handleClose}>
|
<RippleButton variant="outline" onClick={handleClose}>
|
||||||
Cancel
|
{t("common.buttons.cancel")}
|
||||||
</RippleButton>
|
</RippleButton>
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
isLoading={isSaving}
|
isLoading={isSaving}
|
||||||
@@ -1205,7 +1170,7 @@ export function SettingsDialog({
|
|||||||
}}
|
}}
|
||||||
disabled={isLoading || !hasChanges}
|
disabled={isLoading || !hasChanges}
|
||||||
>
|
>
|
||||||
Save Settings
|
{t("common.buttons.saveSettings")}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ const DEVICE_LINK_URL = "https://donutbrowser.com/auth/link";
|
|||||||
interface SyncConfigDialogProps {
|
interface SyncConfigDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: (loginOccurred?: boolean) => void;
|
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 {
|
interface ProxyUsage {
|
||||||
@@ -42,7 +50,11 @@ interface ProxyUsage {
|
|||||||
extra_limit_mb: number;
|
extra_limit_mb: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
export function SyncConfigDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onLoginStarted,
|
||||||
|
}: SyncConfigDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Self-hosted state
|
// Self-hosted state
|
||||||
@@ -58,11 +70,8 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
|||||||
user,
|
user,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
isLoading: isCloudLoading,
|
isLoading: isCloudLoading,
|
||||||
exchangeDeviceCode,
|
|
||||||
logout,
|
logout,
|
||||||
} = useCloudAuth();
|
} = useCloudAuth();
|
||||||
const [linkCode, setLinkCode] = useState("");
|
|
||||||
const [isVerifying, setIsVerifying] = useState(false);
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<string>("cloud");
|
const [activeTab, setActiveTab] = useState<string>("cloud");
|
||||||
const [, setLiveProxyUsage] = useState<ProxyUsage | null>(null);
|
const [, setLiveProxyUsage] = useState<ProxyUsage | null>(null);
|
||||||
@@ -103,7 +112,6 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
|||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setConnectionStatus("unknown");
|
setConnectionStatus("unknown");
|
||||||
void loadSettings();
|
void loadSettings();
|
||||||
setLinkCode("");
|
|
||||||
void invoke<ProxyUsage | null>("cloud_get_proxy_usage")
|
void invoke<ProxyUsage | null>("cloud_get_proxy_usage")
|
||||||
.then(setLiveProxyUsage)
|
.then(setLiveProxyUsage)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -199,32 +207,15 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
|||||||
const handleOpenLogin = useCallback(async () => {
|
const handleOpenLogin = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await invoke("handle_url_open", { url: DEVICE_LINK_URL });
|
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) {
|
} catch (error) {
|
||||||
console.error("Failed to open login link:", error);
|
console.error("Failed to open login link:", error);
|
||||||
showErrorToast(String(error));
|
showErrorToast(String(error));
|
||||||
}
|
}
|
||||||
}, []);
|
}, [onLoginStarted]);
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleCloudLogout = useCallback(async () => {
|
const handleCloudLogout = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -375,37 +366,6 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
|||||||
>
|
>
|
||||||
{t("sync.cloud.openLogin")}
|
{t("sync.cloud.openLogin")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="cloud-link-code">
|
|
||||||
{t("sync.cloud.linkCodeLabel")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="cloud-link-code"
|
|
||||||
placeholder={t("sync.cloud.linkCodePlaceholder")}
|
|
||||||
value={linkCode}
|
|
||||||
onChange={(e) => {
|
|
||||||
setLinkCode(e.target.value);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && linkCode.trim()) {
|
|
||||||
void handleVerifyCode();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
autoComplete="off"
|
|
||||||
spellCheck={false}
|
|
||||||
/>
|
|
||||||
<LoadingButton
|
|
||||||
onClick={() => void handleVerifyCode()}
|
|
||||||
isLoading={isVerifying}
|
|
||||||
disabled={!linkCode.trim()}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isVerifying
|
|
||||||
? t("sync.cloud.loggingIn")
|
|
||||||
: t("sync.cloud.verifyAndLogin")}
|
|
||||||
</LoadingButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -452,7 +412,11 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
|||||||
setShowToken(!showToken);
|
setShowToken(!showToken);
|
||||||
}}
|
}}
|
||||||
className="absolute right-3 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
|
className="absolute right-3 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
|
||||||
aria-label={showToken ? "Hide token" : "Show token"}
|
aria-label={
|
||||||
|
showToken
|
||||||
|
? t("common.aria.hideToken")
|
||||||
|
: t("common.aria.showToken")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{showToken ? (
|
{showToken ? (
|
||||||
<LuEyeOff className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
<LuEyeOff className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { LuCheck, LuCopy } from "react-icons/lu";
|
import { LuCheck, LuCopy } from "react-icons/lu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { showSuccessToast } from "@/lib/toast-utils";
|
import { showSuccessToast } from "@/lib/toast-utils";
|
||||||
@@ -26,6 +27,7 @@ export function CopyToClipboard({
|
|||||||
className,
|
className,
|
||||||
successMessage = "Copied to clipboard",
|
successMessage = "Copied to clipboard",
|
||||||
}: CopyToClipboardProps) {
|
}: CopyToClipboardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const copyToClipboard = useCallback(async () => {
|
const copyToClipboard = useCallback(async () => {
|
||||||
@@ -47,9 +49,11 @@ export function CopyToClipboard({
|
|||||||
size={size}
|
size={size}
|
||||||
className={`relative ${className ?? ""}`}
|
className={`relative ${className ?? ""}`}
|
||||||
onClick={copyToClipboard}
|
onClick={copyToClipboard}
|
||||||
aria-label={copied ? "Copied" : "Copy to clipboard"}
|
aria-label={copied ? t("common.aria.copied") : t("common.aria.copy")}
|
||||||
>
|
>
|
||||||
<span className="sr-only">{copied ? "Copied" : "Copy"}</span>
|
<span className="sr-only">
|
||||||
|
{copied ? t("common.srOnly.copied") : t("common.srOnly.copy")}
|
||||||
|
</span>
|
||||||
<LuCopy
|
<LuCopy
|
||||||
className={`h-4 w-4 transition-all duration-300 ${
|
className={`h-4 w-4 transition-all duration-300 ${
|
||||||
copied ? "scale-0" : "scale-100"
|
copied ? "scale-0" : "scale-100"
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ function DialogContent({
|
|||||||
}}
|
}}
|
||||||
transition={transition}
|
transition={transition}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg sm:max-w-lg",
|
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export function usePermissions(): UsePermissionsReturn {
|
|||||||
|
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
void checkPermissions();
|
void checkPermissions();
|
||||||
}, 500);
|
}, 5000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) {
|
if (intervalRef.current) {
|
||||||
|
|||||||
+53
-16
@@ -60,7 +60,8 @@
|
|||||||
"optional": "Optional",
|
"optional": "Optional",
|
||||||
"required": "Required",
|
"required": "Required",
|
||||||
"unknownProfile": "Unknown",
|
"unknownProfile": "Unknown",
|
||||||
"mode": "Mode"
|
"mode": "Mode",
|
||||||
|
"never": "Never"
|
||||||
},
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"days": "days",
|
"days": "days",
|
||||||
@@ -72,7 +73,11 @@
|
|||||||
"aria": {
|
"aria": {
|
||||||
"selectAll": "Select all",
|
"selectAll": "Select all",
|
||||||
"selectRow": "Select row",
|
"selectRow": "Select row",
|
||||||
"selectProfile": "Select profile"
|
"selectProfile": "Select profile",
|
||||||
|
"copy": "Copy to clipboard",
|
||||||
|
"copied": "Copied",
|
||||||
|
"showToken": "Show token",
|
||||||
|
"hideToken": "Hide token"
|
||||||
},
|
},
|
||||||
"keys": {
|
"keys": {
|
||||||
"escape": "Escape"
|
"escape": "Escape"
|
||||||
@@ -87,7 +92,11 @@
|
|||||||
"title": "Command Palette",
|
"title": "Command Palette",
|
||||||
"description": "Search for a command to run..."
|
"description": "Search for a command to run..."
|
||||||
},
|
},
|
||||||
"noResults": "No results found."
|
"noResults": "No results found.",
|
||||||
|
"srOnly": {
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
@@ -152,7 +161,7 @@
|
|||||||
"commercial": {
|
"commercial": {
|
||||||
"title": "Commercial License",
|
"title": "Commercial License",
|
||||||
"trialActive": "Trial: {{days}} days, {{hours}} hours remaining",
|
"trialActive": "Trial: {{days}} days, {{hours}} hours remaining",
|
||||||
"trialActiveDescription": "Commercial use is free during the trial period",
|
"trialActiveDescription": "Commercial use is free during the trial. When it ends, all features keep working — personal use stays free, only commercial use will require a license.",
|
||||||
"trialExpired": "Trial expired",
|
"trialExpired": "Trial expired",
|
||||||
"trialExpiredDescription": "Personal use remains free. Commercial use requires a license."
|
"trialExpiredDescription": "Personal use remains free. Commercial use requires a license."
|
||||||
},
|
},
|
||||||
@@ -196,7 +205,8 @@
|
|||||||
"group": "Group",
|
"group": "Group",
|
||||||
"proxy": "Proxy / VPN",
|
"proxy": "Proxy / VPN",
|
||||||
"lastLaunch": "Last Launch",
|
"lastLaunch": "Last Launch",
|
||||||
"empty": "No profiles found."
|
"empty": "No profiles found.",
|
||||||
|
"notSelected": "Not Selected"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"launch": "Launch",
|
"launch": "Launch",
|
||||||
@@ -488,7 +498,8 @@
|
|||||||
"deleteGroupAndProfiles": "Delete Group & Profiles",
|
"deleteGroupAndProfiles": "Delete Group & Profiles",
|
||||||
"loadProfilesFailed": "Failed to load profiles",
|
"loadProfilesFailed": "Failed to load profiles",
|
||||||
"unknownGroup": "Unknown Group",
|
"unknownGroup": "Unknown Group",
|
||||||
"profileGroupsAriaLabel": "Profile groups"
|
"profileGroupsAriaLabel": "Profile groups",
|
||||||
|
"loading": "Loading groups..."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"mode": {
|
"mode": {
|
||||||
@@ -631,7 +642,8 @@
|
|||||||
"mcpAcceptTermsFirst": "(Accept Wayfern terms in Settings first)",
|
"mcpAcceptTermsFirst": "(Accept Wayfern terms in Settings first)",
|
||||||
"mcpStarted": "MCP server started on port {{port}}",
|
"mcpStarted": "MCP server started on port {{port}}",
|
||||||
"mcpStopped": "MCP server stopped",
|
"mcpStopped": "MCP server stopped",
|
||||||
"mcpToggleFailed": "Failed to toggle MCP server"
|
"mcpToggleFailed": "Failed to toggle MCP server",
|
||||||
|
"openSettings": "Open Integrations Settings"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Import Profile",
|
"title": "Import Profile",
|
||||||
@@ -711,6 +723,10 @@
|
|||||||
"webrtc": "Block WebRTC",
|
"webrtc": "Block WebRTC",
|
||||||
"webgl": "Block WebGL"
|
"webgl": "Block WebGL"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"shared": {
|
||||||
|
"browserBehavior": "Browser Behavior",
|
||||||
|
"allowAddonsOpenTabs": "Allow browser addons to open new tabs automatically"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cookies": {
|
"cookies": {
|
||||||
@@ -875,7 +891,8 @@
|
|||||||
"loadProxiesFailed": "Failed to load proxies: {{error}}",
|
"loadProxiesFailed": "Failed to load proxies: {{error}}",
|
||||||
"setupProxyListenersFailed": "Failed to setup proxy event listeners: {{error}}",
|
"setupProxyListenersFailed": "Failed to setup proxy event listeners: {{error}}",
|
||||||
"loadVpnConfigsFailed": "Failed to load VPN configs: {{error}}",
|
"loadVpnConfigsFailed": "Failed to load VPN configs: {{error}}",
|
||||||
"setupVpnListenersFailed": "Failed to setup VPN event listeners: {{error}}"
|
"setupVpnListenersFailed": "Failed to setup VPN event listeners: {{error}}",
|
||||||
|
"themeNotFound": "Tokyo Night theme not found"
|
||||||
},
|
},
|
||||||
"browser": {
|
"browser": {
|
||||||
"camoufox": "Camoufox",
|
"camoufox": "Camoufox",
|
||||||
@@ -1124,7 +1141,9 @@
|
|||||||
"syncEnabled": "Sync enabled",
|
"syncEnabled": "Sync enabled",
|
||||||
"syncDisabled": "Sync disabled",
|
"syncDisabled": "Sync disabled",
|
||||||
"syncEnableTooltip": "Enable sync",
|
"syncEnableTooltip": "Enable sync",
|
||||||
"syncDisableTooltip": "Disable sync"
|
"syncDisableTooltip": "Disable sync",
|
||||||
|
"loadGroupsFailed": "Failed to load extension groups",
|
||||||
|
"assignGroupFailed": "Failed to assign extension group"
|
||||||
},
|
},
|
||||||
"pro": {
|
"pro": {
|
||||||
"badge": "PRO",
|
"badge": "PRO",
|
||||||
@@ -1256,12 +1275,11 @@
|
|||||||
"importedSuccess": "Successfully imported profile \"{{name}}\"",
|
"importedSuccess": "Successfully imported profile \"{{name}}\"",
|
||||||
"notInstalled": "{{browser}} is not installed. Please download {{browser}} first from the main window, then try importing again.",
|
"notInstalled": "{{browser}} is not installed. Please download {{browser}} first from the main window, then try importing again.",
|
||||||
"importFailed": "Failed to import profile: {{error}}",
|
"importFailed": "Failed to import profile: {{error}}",
|
||||||
"importedAsPrefix": "This profile will be imported as a",
|
|
||||||
"importedAsSuffix": "profile.",
|
|
||||||
"proxyOptional": "Proxy (Optional)",
|
"proxyOptional": "Proxy (Optional)",
|
||||||
"noProxy": "No proxy",
|
"noProxy": "No proxy",
|
||||||
"nextButton": "Next",
|
"nextButton": "Next",
|
||||||
"importButton": "Import"
|
"importButton": "Import",
|
||||||
|
"importedAs": "This profile will be imported as a {{browser}} profile."
|
||||||
},
|
},
|
||||||
"syncTooltips": {
|
"syncTooltips": {
|
||||||
"syncing": "Syncing...",
|
"syncing": "Syncing...",
|
||||||
@@ -1424,7 +1442,11 @@
|
|||||||
"grantAccessButton": "Grant Access",
|
"grantAccessButton": "Grant Access",
|
||||||
"requestSuccessMicrophone": "Microphone Access permission requested",
|
"requestSuccessMicrophone": "Microphone Access permission requested",
|
||||||
"requestSuccessCamera": "Camera 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": {
|
"traffic": {
|
||||||
"title": "Traffic Details",
|
"title": "Traffic Details",
|
||||||
@@ -1503,7 +1525,12 @@
|
|||||||
"syncTooltipNotSynced": "Not synced",
|
"syncTooltipNotSynced": "Not synced",
|
||||||
"noTags": "No tags",
|
"noTags": "No tags",
|
||||||
"syncTooltipCloseToSync": "Close the profile to sync",
|
"syncTooltipCloseToSync": "Close the profile to sync",
|
||||||
"syncTooltipDisabledWithLast": "Sync disabled, last sync {{time}}"
|
"syncTooltipDisabledWithLast": "Sync disabled, last sync {{time}}",
|
||||||
|
"addTagsPlaceholder": "Add tags",
|
||||||
|
"tagsHeader": "Tags",
|
||||||
|
"noteHeader": "Note",
|
||||||
|
"vpnsHeading": "VPNs",
|
||||||
|
"createByCountryHeading": "Create by country"
|
||||||
},
|
},
|
||||||
"releaseTypeSelector": {
|
"releaseTypeSelector": {
|
||||||
"noReleaseTypes": "No release types available.",
|
"noReleaseTypes": "No release types available.",
|
||||||
@@ -1521,7 +1548,14 @@
|
|||||||
"appUpdate": {
|
"appUpdate": {
|
||||||
"toast": {
|
"toast": {
|
||||||
"updateFailed": "Failed to update Donut Browser",
|
"updateFailed": "Failed to update Donut Browser",
|
||||||
"restartFailed": "Failed to restart"
|
"restartFailed": "Failed to restart",
|
||||||
|
"updateReady": "Update ready, restart to apply",
|
||||||
|
"manualDownloadRequired": "Manual download required",
|
||||||
|
"restartNow": "Restart Now",
|
||||||
|
"viewRelease": "View Release",
|
||||||
|
"later": "Later",
|
||||||
|
"uploading": "Uploading",
|
||||||
|
"downloading": "Downloading"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"browserDownload": {
|
"browserDownload": {
|
||||||
@@ -1532,7 +1566,10 @@
|
|||||||
"downloadFailed": "Failed to download {{browser}} {{version}}",
|
"downloadFailed": "Failed to download {{browser}} {{version}}",
|
||||||
"calculating": "calculating...",
|
"calculating": "calculating...",
|
||||||
"extractionFailed": "{{browser}} {{version}}: extraction failed",
|
"extractionFailed": "{{browser}} {{version}}: extraction failed",
|
||||||
"extractionFailedDescription": "The corrupt file was deleted. It will be re-downloaded on next attempt."
|
"extractionFailedDescription": "The corrupt file was deleted. It will be re-downloaded on next attempt.",
|
||||||
|
"extracting": "Extracting browser files... Please do not close the app.",
|
||||||
|
"verifying": "Verifying browser files...",
|
||||||
|
"downloadingRolling": "Downloading rolling release build..."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"versionUpdater": {
|
"versionUpdater": {
|
||||||
|
|||||||
+67
-30
@@ -60,7 +60,8 @@
|
|||||||
"optional": "Opcional",
|
"optional": "Opcional",
|
||||||
"required": "Requerido",
|
"required": "Requerido",
|
||||||
"unknownProfile": "Desconocido",
|
"unknownProfile": "Desconocido",
|
||||||
"mode": "Modo"
|
"mode": "Modo",
|
||||||
|
"never": "Nunca"
|
||||||
},
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"days": "días",
|
"days": "días",
|
||||||
@@ -72,7 +73,11 @@
|
|||||||
"aria": {
|
"aria": {
|
||||||
"selectAll": "Seleccionar todo",
|
"selectAll": "Seleccionar todo",
|
||||||
"selectRow": "Seleccionar fila",
|
"selectRow": "Seleccionar fila",
|
||||||
"selectProfile": "Seleccionar perfil"
|
"selectProfile": "Seleccionar perfil",
|
||||||
|
"copy": "Copiar al portapapeles",
|
||||||
|
"copied": "Copiado",
|
||||||
|
"showToken": "Mostrar token",
|
||||||
|
"hideToken": "Ocultar token"
|
||||||
},
|
},
|
||||||
"keys": {
|
"keys": {
|
||||||
"escape": "Escape"
|
"escape": "Escape"
|
||||||
@@ -87,7 +92,11 @@
|
|||||||
"title": "Paleta de comandos",
|
"title": "Paleta de comandos",
|
||||||
"description": "Busca un comando para ejecutar..."
|
"description": "Busca un comando para ejecutar..."
|
||||||
},
|
},
|
||||||
"noResults": "No se encontraron resultados."
|
"noResults": "No se encontraron resultados.",
|
||||||
|
"srOnly": {
|
||||||
|
"copy": "Copiar",
|
||||||
|
"copied": "Copiado"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Configuración",
|
"title": "Configuración",
|
||||||
@@ -152,7 +161,7 @@
|
|||||||
"commercial": {
|
"commercial": {
|
||||||
"title": "Licencia Comercial",
|
"title": "Licencia Comercial",
|
||||||
"trialActive": "Prueba: {{days}} días, {{hours}} horas restantes",
|
"trialActive": "Prueba: {{days}} días, {{hours}} horas restantes",
|
||||||
"trialActiveDescription": "El uso comercial es gratuito durante el período de prueba",
|
"trialActiveDescription": "El uso comercial es gratuito durante la prueba. Al finalizar, todas las funciones siguen funcionando — el uso personal sigue siendo gratuito, solo el uso comercial requerirá una licencia.",
|
||||||
"trialExpired": "Prueba expirada",
|
"trialExpired": "Prueba expirada",
|
||||||
"trialExpiredDescription": "El uso personal sigue siendo gratuito. El uso comercial requiere una licencia."
|
"trialExpiredDescription": "El uso personal sigue siendo gratuito. El uso comercial requiere una licencia."
|
||||||
},
|
},
|
||||||
@@ -196,7 +205,8 @@
|
|||||||
"group": "Grupo",
|
"group": "Grupo",
|
||||||
"proxy": "Proxy / VPN",
|
"proxy": "Proxy / VPN",
|
||||||
"lastLaunch": "Último Inicio",
|
"lastLaunch": "Último Inicio",
|
||||||
"empty": "No se encontraron perfiles."
|
"empty": "No se encontraron perfiles.",
|
||||||
|
"notSelected": "No seleccionado"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"launch": "Iniciar",
|
"launch": "Iniciar",
|
||||||
@@ -488,7 +498,8 @@
|
|||||||
"deleteGroupAndProfiles": "Eliminar Grupo y Perfiles",
|
"deleteGroupAndProfiles": "Eliminar Grupo y Perfiles",
|
||||||
"loadProfilesFailed": "Error al cargar los perfiles",
|
"loadProfilesFailed": "Error al cargar los perfiles",
|
||||||
"unknownGroup": "Grupo desconocido",
|
"unknownGroup": "Grupo desconocido",
|
||||||
"profileGroupsAriaLabel": "Grupos de perfiles"
|
"profileGroupsAriaLabel": "Grupos de perfiles",
|
||||||
|
"loading": "Cargando grupos..."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"mode": {
|
"mode": {
|
||||||
@@ -631,7 +642,8 @@
|
|||||||
"mcpAcceptTermsFirst": "(Acepta primero los términos de Wayfern en Configuración)",
|
"mcpAcceptTermsFirst": "(Acepta primero los términos de Wayfern en Configuración)",
|
||||||
"mcpStarted": "Servidor MCP iniciado en puerto {{port}}",
|
"mcpStarted": "Servidor MCP iniciado en puerto {{port}}",
|
||||||
"mcpStopped": "Servidor MCP detenido",
|
"mcpStopped": "Servidor MCP detenido",
|
||||||
"mcpToggleFailed": "Error al alternar el servidor MCP"
|
"mcpToggleFailed": "Error al alternar el servidor MCP",
|
||||||
|
"openSettings": "Abrir configuración de integraciones"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Importar Perfil",
|
"title": "Importar Perfil",
|
||||||
@@ -711,6 +723,10 @@
|
|||||||
"webrtc": "Bloquear WebRTC",
|
"webrtc": "Bloquear WebRTC",
|
||||||
"webgl": "Bloquear WebGL"
|
"webgl": "Bloquear WebGL"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"shared": {
|
||||||
|
"browserBehavior": "Comportamiento del navegador",
|
||||||
|
"allowAddonsOpenTabs": "Permitir que los complementos abran nuevas pestañas automáticamente"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cookies": {
|
"cookies": {
|
||||||
@@ -875,7 +891,8 @@
|
|||||||
"loadProxiesFailed": "Error al cargar los proxies: {{error}}",
|
"loadProxiesFailed": "Error al cargar los proxies: {{error}}",
|
||||||
"setupProxyListenersFailed": "Error al configurar los listeners de eventos de proxies: {{error}}",
|
"setupProxyListenersFailed": "Error al configurar los listeners de eventos de proxies: {{error}}",
|
||||||
"loadVpnConfigsFailed": "Error al cargar las configuraciones de VPN: {{error}}",
|
"loadVpnConfigsFailed": "Error al cargar las configuraciones de VPN: {{error}}",
|
||||||
"setupVpnListenersFailed": "Error al configurar los listeners de eventos de VPN: {{error}}"
|
"setupVpnListenersFailed": "Error al configurar los listeners de eventos de VPN: {{error}}",
|
||||||
|
"themeNotFound": "Tema Tokyo Night no encontrado"
|
||||||
},
|
},
|
||||||
"browser": {
|
"browser": {
|
||||||
"camoufox": "Camoufox",
|
"camoufox": "Camoufox",
|
||||||
@@ -901,15 +918,15 @@
|
|||||||
"blockWebRTC": "Bloquear WebRTC",
|
"blockWebRTC": "Bloquear WebRTC",
|
||||||
"blockWebGL": "Bloquear WebGL",
|
"blockWebGL": "Bloquear WebGL",
|
||||||
"navigatorProperties": "Propiedades del navegador",
|
"navigatorProperties": "Propiedades del navegador",
|
||||||
"userAgent": "User Agent",
|
"userAgent": "Agente de usuario",
|
||||||
"userAgentAndPlatform": "User Agent y plataforma",
|
"userAgentAndPlatform": "User Agent y plataforma",
|
||||||
"platform": "Plataforma",
|
"platform": "Plataforma",
|
||||||
"platformVersion": "Versión de plataforma",
|
"platformVersion": "Versión de plataforma",
|
||||||
"appVersion": "Versión de la aplicación",
|
"appVersion": "Versión de la aplicación",
|
||||||
"osCpu": "OS CPU",
|
"osCpu": "CPU del SO",
|
||||||
"hardwareConcurrency": "Concurrencia de hardware",
|
"hardwareConcurrency": "Concurrencia de hardware",
|
||||||
"maxTouchPoints": "Puntos táctiles máximos",
|
"maxTouchPoints": "Puntos táctiles máximos",
|
||||||
"doNotTrack": "Do Not Track",
|
"doNotTrack": "No rastrear",
|
||||||
"selectDntPlaceholder": "Seleccionar valor DNT",
|
"selectDntPlaceholder": "Seleccionar valor DNT",
|
||||||
"dntAllowed": "0 (rastreo permitido)",
|
"dntAllowed": "0 (rastreo permitido)",
|
||||||
"dntNotAllowed": "1 (rastreo no permitido)",
|
"dntNotAllowed": "1 (rastreo no permitido)",
|
||||||
@@ -931,8 +948,8 @@
|
|||||||
"outerHeight": "Alto exterior",
|
"outerHeight": "Alto exterior",
|
||||||
"innerWidth": "Ancho interior",
|
"innerWidth": "Ancho interior",
|
||||||
"innerHeight": "Alto interior",
|
"innerHeight": "Alto interior",
|
||||||
"screenX": "Screen X",
|
"screenX": "Pantalla X",
|
||||||
"screenY": "Screen Y",
|
"screenY": "Pantalla Y",
|
||||||
"geolocation": "Geolocalización",
|
"geolocation": "Geolocalización",
|
||||||
"timezoneAndGeolocation": "Zona horaria y geolocalización",
|
"timezoneAndGeolocation": "Zona horaria y geolocalización",
|
||||||
"timezoneGeolocationDescription": "Estos valores anulan las APIs de zona horaria y geolocalización del navegador.",
|
"timezoneGeolocationDescription": "Estos valores anulan las APIs de zona horaria y geolocalización del navegador.",
|
||||||
@@ -946,15 +963,15 @@
|
|||||||
"region": "Región",
|
"region": "Región",
|
||||||
"script": "Script",
|
"script": "Script",
|
||||||
"webglProperties": "Propiedades de WebGL",
|
"webglProperties": "Propiedades de WebGL",
|
||||||
"webglVendor": "WebGL Vendor",
|
"webglVendor": "Proveedor WebGL",
|
||||||
"webglRenderer": "WebGL Renderer",
|
"webglRenderer": "Renderizador WebGL",
|
||||||
"webglParameters": "Parámetros de WebGL",
|
"webglParameters": "Parámetros de WebGL",
|
||||||
"webglParametersJson": "Parámetros de WebGL (JSON)",
|
"webglParametersJson": "Parámetros de WebGL (JSON)",
|
||||||
"webgl2Parameters": "Parámetros de WebGL2",
|
"webgl2Parameters": "Parámetros de WebGL2",
|
||||||
"webglShaderPrecisionFormats": "Formatos de precisión de WebGL Shader",
|
"webglShaderPrecisionFormats": "Formatos de precisión de shader WebGL",
|
||||||
"webgl2ShaderPrecisionFormats": "Formatos de precisión de WebGL2 Shader",
|
"webgl2ShaderPrecisionFormats": "Formatos de precisión de shader WebGL2",
|
||||||
"canvasFingerprint": "Canvas Fingerprint",
|
"canvasFingerprint": "Canvas Fingerprint",
|
||||||
"canvasNoiseSeed": "Canvas Noise Seed",
|
"canvasNoiseSeed": "Semilla de ruido de Canvas",
|
||||||
"canvasNoiseSeedDescription": "Esta semilla se usa para generar una huella digital de Canvas consistente pero única. Cada perfil debe tener una semilla diferente.",
|
"canvasNoiseSeedDescription": "Esta semilla se usa para generar una huella digital de Canvas consistente pero única. Cada perfil debe tener una semilla diferente.",
|
||||||
"fonts": "Fuentes",
|
"fonts": "Fuentes",
|
||||||
"fontsJson": "Fuentes (JSON array)",
|
"fontsJson": "Fuentes (JSON array)",
|
||||||
@@ -975,8 +992,8 @@
|
|||||||
"maxChannelCount": "Número máximo de canales",
|
"maxChannelCount": "Número máximo de canales",
|
||||||
"vendorInfo": "Información del proveedor",
|
"vendorInfo": "Información del proveedor",
|
||||||
"vendor": "Proveedor",
|
"vendor": "Proveedor",
|
||||||
"vendorSub": "Vendor Sub",
|
"vendorSub": "Proveedor Sub",
|
||||||
"productSub": "Product Sub",
|
"productSub": "Producto Sub",
|
||||||
"brand": "Marca",
|
"brand": "Marca",
|
||||||
"brandVersion": "Versión de marca",
|
"brandVersion": "Versión de marca",
|
||||||
"proFeature": "Esta es una función Pro",
|
"proFeature": "Esta es una función Pro",
|
||||||
@@ -1124,7 +1141,9 @@
|
|||||||
"syncEnabled": "Sincronización habilitada",
|
"syncEnabled": "Sincronización habilitada",
|
||||||
"syncDisabled": "Sincronización deshabilitada",
|
"syncDisabled": "Sincronización deshabilitada",
|
||||||
"syncEnableTooltip": "Habilitar sincronización",
|
"syncEnableTooltip": "Habilitar sincronización",
|
||||||
"syncDisableTooltip": "Deshabilitar sincronización"
|
"syncDisableTooltip": "Deshabilitar sincronización",
|
||||||
|
"loadGroupsFailed": "Error al cargar grupos de extensiones",
|
||||||
|
"assignGroupFailed": "Error al asignar grupo de extensiones"
|
||||||
},
|
},
|
||||||
"pro": {
|
"pro": {
|
||||||
"badge": "PRO",
|
"badge": "PRO",
|
||||||
@@ -1256,12 +1275,11 @@
|
|||||||
"importedSuccess": "Perfil \"{{name}}\" importado correctamente",
|
"importedSuccess": "Perfil \"{{name}}\" importado correctamente",
|
||||||
"notInstalled": "{{browser}} no está instalado. Por favor descarga {{browser}} primero desde la ventana principal y luego intenta importar de nuevo.",
|
"notInstalled": "{{browser}} no está instalado. Por favor descarga {{browser}} primero desde la ventana principal y luego intenta importar de nuevo.",
|
||||||
"importFailed": "Error al importar el perfil: {{error}}",
|
"importFailed": "Error al importar el perfil: {{error}}",
|
||||||
"importedAsPrefix": "Este perfil se importará como un perfil de",
|
|
||||||
"importedAsSuffix": ".",
|
|
||||||
"proxyOptional": "Proxy (Opcional)",
|
"proxyOptional": "Proxy (Opcional)",
|
||||||
"noProxy": "Sin proxy",
|
"noProxy": "Sin proxy",
|
||||||
"nextButton": "Siguiente",
|
"nextButton": "Siguiente",
|
||||||
"importButton": "Importar"
|
"importButton": "Importar",
|
||||||
|
"importedAs": "Este perfil se importará como un perfil de {{browser}}."
|
||||||
},
|
},
|
||||||
"syncTooltips": {
|
"syncTooltips": {
|
||||||
"syncing": "Sincronizando...",
|
"syncing": "Sincronizando...",
|
||||||
@@ -1424,7 +1442,11 @@
|
|||||||
"grantAccessButton": "Conceder acceso",
|
"grantAccessButton": "Conceder acceso",
|
||||||
"requestSuccessMicrophone": "Acceso al micrófono solicitado",
|
"requestSuccessMicrophone": "Acceso al micrófono solicitado",
|
||||||
"requestSuccessCamera": "Acceso a la cámara 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": {
|
"traffic": {
|
||||||
"title": "Detalles de tráfico",
|
"title": "Detalles de tráfico",
|
||||||
@@ -1497,13 +1519,18 @@
|
|||||||
"syncTooltipSyncing": "Sincronizando...",
|
"syncTooltipSyncing": "Sincronizando...",
|
||||||
"syncTooltipSyncedAt": "Sincronizado {{time}}",
|
"syncTooltipSyncedAt": "Sincronizado {{time}}",
|
||||||
"syncTooltipSynced": "Sincronizado",
|
"syncTooltipSynced": "Sincronizado",
|
||||||
"syncTooltipWaiting": "Esperando sincronización",
|
"syncTooltipWaiting": "Esperando para sincronizar",
|
||||||
"syncTooltipErrorWith": "Error de sincronización: {{error}}",
|
"syncTooltipErrorWith": "Error de sincronización: {{error}}",
|
||||||
"syncTooltipError": "Error de sincronización",
|
"syncTooltipError": "Error de sincronización",
|
||||||
"syncTooltipNotSynced": "Sin sincronizar",
|
"syncTooltipNotSynced": "No sincronizado",
|
||||||
"noTags": "Sin etiquetas",
|
"noTags": "Sin etiquetas",
|
||||||
"syncTooltipCloseToSync": "Cierra el perfil para sincronizar",
|
"syncTooltipCloseToSync": "Cierra el perfil para sincronizar",
|
||||||
"syncTooltipDisabledWithLast": "Sincronización desactivada, última sincronización {{time}}"
|
"syncTooltipDisabledWithLast": "Sincronización desactivada, última sincronización {{time}}",
|
||||||
|
"addTagsPlaceholder": "Añadir etiquetas",
|
||||||
|
"tagsHeader": "Etiquetas",
|
||||||
|
"noteHeader": "Nota",
|
||||||
|
"vpnsHeading": "VPN",
|
||||||
|
"createByCountryHeading": "Crear por país"
|
||||||
},
|
},
|
||||||
"releaseTypeSelector": {
|
"releaseTypeSelector": {
|
||||||
"noReleaseTypes": "No hay tipos de versión disponibles.",
|
"noReleaseTypes": "No hay tipos de versión disponibles.",
|
||||||
@@ -1521,7 +1548,14 @@
|
|||||||
"appUpdate": {
|
"appUpdate": {
|
||||||
"toast": {
|
"toast": {
|
||||||
"updateFailed": "Error al actualizar Donut Browser",
|
"updateFailed": "Error al actualizar Donut Browser",
|
||||||
"restartFailed": "Error al reiniciar"
|
"restartFailed": "Error al reiniciar",
|
||||||
|
"updateReady": "Actualización lista, reinicia para aplicar",
|
||||||
|
"manualDownloadRequired": "Descarga manual requerida",
|
||||||
|
"restartNow": "Reiniciar ahora",
|
||||||
|
"viewRelease": "Ver lanzamiento",
|
||||||
|
"later": "Más tarde",
|
||||||
|
"uploading": "Subiendo",
|
||||||
|
"downloading": "Descargando"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"browserDownload": {
|
"browserDownload": {
|
||||||
@@ -1532,7 +1566,10 @@
|
|||||||
"downloadFailed": "Error al descargar {{browser}} {{version}}",
|
"downloadFailed": "Error al descargar {{browser}} {{version}}",
|
||||||
"calculating": "calculando...",
|
"calculating": "calculando...",
|
||||||
"extractionFailed": "{{browser}} {{version}}: error de extracción",
|
"extractionFailed": "{{browser}} {{version}}: error de extracción",
|
||||||
"extractionFailedDescription": "El archivo dañado fue eliminado. Se volverá a descargar en el próximo intento."
|
"extractionFailedDescription": "El archivo dañado fue eliminado. Se volverá a descargar en el próximo intento.",
|
||||||
|
"extracting": "Extrayendo archivos del navegador... No cierre la aplicación.",
|
||||||
|
"verifying": "Verificando archivos del navegador...",
|
||||||
|
"downloadingRolling": "Descargando compilación rolling release..."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"versionUpdater": {
|
"versionUpdater": {
|
||||||
|
|||||||
+66
-29
@@ -60,7 +60,8 @@
|
|||||||
"optional": "Optionnel",
|
"optional": "Optionnel",
|
||||||
"required": "Requis",
|
"required": "Requis",
|
||||||
"unknownProfile": "Inconnu",
|
"unknownProfile": "Inconnu",
|
||||||
"mode": "Mode"
|
"mode": "Mode",
|
||||||
|
"never": "Jamais"
|
||||||
},
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"days": "jours",
|
"days": "jours",
|
||||||
@@ -72,7 +73,11 @@
|
|||||||
"aria": {
|
"aria": {
|
||||||
"selectAll": "Tout sélectionner",
|
"selectAll": "Tout sélectionner",
|
||||||
"selectRow": "Sélectionner la ligne",
|
"selectRow": "Sélectionner la ligne",
|
||||||
"selectProfile": "Sélectionner le profil"
|
"selectProfile": "Sélectionner le profil",
|
||||||
|
"copy": "Copier dans le presse-papiers",
|
||||||
|
"copied": "Copié",
|
||||||
|
"showToken": "Afficher le jeton",
|
||||||
|
"hideToken": "Masquer le jeton"
|
||||||
},
|
},
|
||||||
"keys": {
|
"keys": {
|
||||||
"escape": "Échap"
|
"escape": "Échap"
|
||||||
@@ -87,7 +92,11 @@
|
|||||||
"title": "Palette de commandes",
|
"title": "Palette de commandes",
|
||||||
"description": "Rechercher une commande à exécuter..."
|
"description": "Rechercher une commande à exécuter..."
|
||||||
},
|
},
|
||||||
"noResults": "Aucun résultat trouvé."
|
"noResults": "Aucun résultat trouvé.",
|
||||||
|
"srOnly": {
|
||||||
|
"copy": "Copier",
|
||||||
|
"copied": "Copié"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Paramètres",
|
"title": "Paramètres",
|
||||||
@@ -152,7 +161,7 @@
|
|||||||
"commercial": {
|
"commercial": {
|
||||||
"title": "Licence commerciale",
|
"title": "Licence commerciale",
|
||||||
"trialActive": "Essai: {{days}} jours, {{hours}} heures restantes",
|
"trialActive": "Essai: {{days}} jours, {{hours}} heures restantes",
|
||||||
"trialActiveDescription": "L'utilisation commerciale est gratuite pendant la période d'essai",
|
"trialActiveDescription": "L'utilisation commerciale est gratuite pendant l'essai. À l'expiration, toutes les fonctionnalités continuent de fonctionner — l'utilisation personnelle reste gratuite, seule l'utilisation commerciale nécessitera une licence.",
|
||||||
"trialExpired": "Essai expiré",
|
"trialExpired": "Essai expiré",
|
||||||
"trialExpiredDescription": "L'utilisation personnelle reste gratuite. L'utilisation commerciale nécessite une licence."
|
"trialExpiredDescription": "L'utilisation personnelle reste gratuite. L'utilisation commerciale nécessite une licence."
|
||||||
},
|
},
|
||||||
@@ -196,7 +205,8 @@
|
|||||||
"group": "Groupe",
|
"group": "Groupe",
|
||||||
"proxy": "Proxy / VPN",
|
"proxy": "Proxy / VPN",
|
||||||
"lastLaunch": "Dernier lancement",
|
"lastLaunch": "Dernier lancement",
|
||||||
"empty": "Aucun profil trouvé."
|
"empty": "Aucun profil trouvé.",
|
||||||
|
"notSelected": "Non sélectionné"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"launch": "Lancer",
|
"launch": "Lancer",
|
||||||
@@ -488,7 +498,8 @@
|
|||||||
"deleteGroupAndProfiles": "Supprimer le Groupe et les Profils",
|
"deleteGroupAndProfiles": "Supprimer le Groupe et les Profils",
|
||||||
"loadProfilesFailed": "Échec du chargement des profils",
|
"loadProfilesFailed": "Échec du chargement des profils",
|
||||||
"unknownGroup": "Groupe inconnu",
|
"unknownGroup": "Groupe inconnu",
|
||||||
"profileGroupsAriaLabel": "Groupes de profils"
|
"profileGroupsAriaLabel": "Groupes de profils",
|
||||||
|
"loading": "Chargement des groupes..."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"mode": {
|
"mode": {
|
||||||
@@ -631,7 +642,8 @@
|
|||||||
"mcpAcceptTermsFirst": "(Acceptez d'abord les conditions Wayfern dans les Paramètres)",
|
"mcpAcceptTermsFirst": "(Acceptez d'abord les conditions Wayfern dans les Paramètres)",
|
||||||
"mcpStarted": "Serveur MCP démarré sur le port {{port}}",
|
"mcpStarted": "Serveur MCP démarré sur le port {{port}}",
|
||||||
"mcpStopped": "Serveur MCP arrêté",
|
"mcpStopped": "Serveur MCP arrêté",
|
||||||
"mcpToggleFailed": "Échec du basculement du serveur MCP"
|
"mcpToggleFailed": "Échec du basculement du serveur MCP",
|
||||||
|
"openSettings": "Ouvrir les paramètres d'intégrations"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Importer un profil",
|
"title": "Importer un profil",
|
||||||
@@ -711,6 +723,10 @@
|
|||||||
"webrtc": "Bloquer WebRTC",
|
"webrtc": "Bloquer WebRTC",
|
||||||
"webgl": "Bloquer WebGL"
|
"webgl": "Bloquer WebGL"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"shared": {
|
||||||
|
"browserBehavior": "Comportement du navigateur",
|
||||||
|
"allowAddonsOpenTabs": "Autoriser les modules complémentaires à ouvrir automatiquement de nouveaux onglets"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cookies": {
|
"cookies": {
|
||||||
@@ -875,7 +891,8 @@
|
|||||||
"loadProxiesFailed": "Échec du chargement des proxies : {{error}}",
|
"loadProxiesFailed": "Échec du chargement des proxies : {{error}}",
|
||||||
"setupProxyListenersFailed": "Échec de la configuration des écouteurs d’événements de proxies : {{error}}",
|
"setupProxyListenersFailed": "Échec de la configuration des écouteurs d’événements de proxies : {{error}}",
|
||||||
"loadVpnConfigsFailed": "Échec du chargement des configurations VPN : {{error}}",
|
"loadVpnConfigsFailed": "Échec du chargement des configurations VPN : {{error}}",
|
||||||
"setupVpnListenersFailed": "Échec de la configuration des écouteurs d’événements VPN : {{error}}"
|
"setupVpnListenersFailed": "Échec de la configuration des écouteurs d’événements VPN : {{error}}",
|
||||||
|
"themeNotFound": "Thème Tokyo Night introuvable"
|
||||||
},
|
},
|
||||||
"browser": {
|
"browser": {
|
||||||
"camoufox": "Camoufox",
|
"camoufox": "Camoufox",
|
||||||
@@ -906,10 +923,10 @@
|
|||||||
"platform": "Plateforme",
|
"platform": "Plateforme",
|
||||||
"platformVersion": "Version de la plateforme",
|
"platformVersion": "Version de la plateforme",
|
||||||
"appVersion": "Version de l'application",
|
"appVersion": "Version de l'application",
|
||||||
"osCpu": "OS CPU",
|
"osCpu": "CPU OS",
|
||||||
"hardwareConcurrency": "Concurrence matérielle",
|
"hardwareConcurrency": "Concurrence matérielle",
|
||||||
"maxTouchPoints": "Points tactiles maximum",
|
"maxTouchPoints": "Points tactiles maximum",
|
||||||
"doNotTrack": "Do Not Track",
|
"doNotTrack": "Ne pas suivre",
|
||||||
"selectDntPlaceholder": "Sélectionner la valeur DNT",
|
"selectDntPlaceholder": "Sélectionner la valeur DNT",
|
||||||
"dntAllowed": "0 (suivi autorisé)",
|
"dntAllowed": "0 (suivi autorisé)",
|
||||||
"dntNotAllowed": "1 (suivi non autorisé)",
|
"dntNotAllowed": "1 (suivi non autorisé)",
|
||||||
@@ -931,8 +948,8 @@
|
|||||||
"outerHeight": "Hauteur extérieure",
|
"outerHeight": "Hauteur extérieure",
|
||||||
"innerWidth": "Largeur intérieure",
|
"innerWidth": "Largeur intérieure",
|
||||||
"innerHeight": "Hauteur intérieure",
|
"innerHeight": "Hauteur intérieure",
|
||||||
"screenX": "Screen X",
|
"screenX": "Écran X",
|
||||||
"screenY": "Screen Y",
|
"screenY": "Écran Y",
|
||||||
"geolocation": "Géolocalisation",
|
"geolocation": "Géolocalisation",
|
||||||
"timezoneAndGeolocation": "Fuseau horaire et géolocalisation",
|
"timezoneAndGeolocation": "Fuseau horaire et géolocalisation",
|
||||||
"timezoneGeolocationDescription": "Ces valeurs remplacent les APIs de fuseau horaire et de géolocalisation du navigateur.",
|
"timezoneGeolocationDescription": "Ces valeurs remplacent les APIs de fuseau horaire et de géolocalisation du navigateur.",
|
||||||
@@ -946,15 +963,15 @@
|
|||||||
"region": "Région",
|
"region": "Région",
|
||||||
"script": "Script",
|
"script": "Script",
|
||||||
"webglProperties": "Propriétés WebGL",
|
"webglProperties": "Propriétés WebGL",
|
||||||
"webglVendor": "WebGL Vendor",
|
"webglVendor": "Fournisseur WebGL",
|
||||||
"webglRenderer": "WebGL Renderer",
|
"webglRenderer": "Moteur de rendu WebGL",
|
||||||
"webglParameters": "Paramètres WebGL",
|
"webglParameters": "Paramètres WebGL",
|
||||||
"webglParametersJson": "Paramètres WebGL (JSON)",
|
"webglParametersJson": "Paramètres WebGL (JSON)",
|
||||||
"webgl2Parameters": "Paramètres WebGL2",
|
"webgl2Parameters": "Paramètres WebGL2",
|
||||||
"webglShaderPrecisionFormats": "Formats de précision WebGL Shader",
|
"webglShaderPrecisionFormats": "Formats de précision shader WebGL",
|
||||||
"webgl2ShaderPrecisionFormats": "Formats de précision WebGL2 Shader",
|
"webgl2ShaderPrecisionFormats": "Formats de précision shader WebGL2",
|
||||||
"canvasFingerprint": "Canvas Fingerprint",
|
"canvasFingerprint": "Canvas Fingerprint",
|
||||||
"canvasNoiseSeed": "Canvas Noise Seed",
|
"canvasNoiseSeed": "Graine de bruit Canvas",
|
||||||
"canvasNoiseSeedDescription": "Cette graine est utilisée pour générer une empreinte Canvas cohérente mais unique. Chaque profil doit avoir une graine différente.",
|
"canvasNoiseSeedDescription": "Cette graine est utilisée pour générer une empreinte Canvas cohérente mais unique. Chaque profil doit avoir une graine différente.",
|
||||||
"fonts": "Polices",
|
"fonts": "Polices",
|
||||||
"fontsJson": "Polices (JSON array)",
|
"fontsJson": "Polices (JSON array)",
|
||||||
@@ -975,8 +992,8 @@
|
|||||||
"maxChannelCount": "Nombre maximum de canaux",
|
"maxChannelCount": "Nombre maximum de canaux",
|
||||||
"vendorInfo": "Informations du fournisseur",
|
"vendorInfo": "Informations du fournisseur",
|
||||||
"vendor": "Fournisseur",
|
"vendor": "Fournisseur",
|
||||||
"vendorSub": "Vendor Sub",
|
"vendorSub": "Fournisseur Sub",
|
||||||
"productSub": "Product Sub",
|
"productSub": "Produit Sub",
|
||||||
"brand": "Marque",
|
"brand": "Marque",
|
||||||
"brandVersion": "Version de la marque",
|
"brandVersion": "Version de la marque",
|
||||||
"proFeature": "Ceci est une fonctionnalité Pro",
|
"proFeature": "Ceci est une fonctionnalité Pro",
|
||||||
@@ -1124,7 +1141,9 @@
|
|||||||
"syncEnabled": "Synchronisation activée",
|
"syncEnabled": "Synchronisation activée",
|
||||||
"syncDisabled": "Synchronisation désactivée",
|
"syncDisabled": "Synchronisation désactivée",
|
||||||
"syncEnableTooltip": "Activer la synchronisation",
|
"syncEnableTooltip": "Activer la synchronisation",
|
||||||
"syncDisableTooltip": "Désactiver la synchronisation"
|
"syncDisableTooltip": "Désactiver la synchronisation",
|
||||||
|
"loadGroupsFailed": "Échec du chargement des groupes d'extensions",
|
||||||
|
"assignGroupFailed": "Échec de l'attribution du groupe d'extensions"
|
||||||
},
|
},
|
||||||
"pro": {
|
"pro": {
|
||||||
"badge": "PRO",
|
"badge": "PRO",
|
||||||
@@ -1256,12 +1275,11 @@
|
|||||||
"importedSuccess": "Profil « {{name}} » importé avec succès",
|
"importedSuccess": "Profil « {{name}} » importé avec succès",
|
||||||
"notInstalled": "{{browser}} n'est pas installé. Veuillez télécharger {{browser}} depuis la fenêtre principale puis réessayer.",
|
"notInstalled": "{{browser}} n'est pas installé. Veuillez télécharger {{browser}} depuis la fenêtre principale puis réessayer.",
|
||||||
"importFailed": "Échec de l'import du profil : {{error}}",
|
"importFailed": "Échec de l'import du profil : {{error}}",
|
||||||
"importedAsPrefix": "Ce profil sera importé en tant que profil",
|
|
||||||
"importedAsSuffix": ".",
|
|
||||||
"proxyOptional": "Proxy (optionnel)",
|
"proxyOptional": "Proxy (optionnel)",
|
||||||
"noProxy": "Aucun proxy",
|
"noProxy": "Aucun proxy",
|
||||||
"nextButton": "Suivant",
|
"nextButton": "Suivant",
|
||||||
"importButton": "Importer"
|
"importButton": "Importer",
|
||||||
|
"importedAs": "Ce profil sera importé en tant que profil {{browser}}."
|
||||||
},
|
},
|
||||||
"syncTooltips": {
|
"syncTooltips": {
|
||||||
"syncing": "Synchronisation...",
|
"syncing": "Synchronisation...",
|
||||||
@@ -1424,7 +1442,11 @@
|
|||||||
"grantAccessButton": "Accorder l'accès",
|
"grantAccessButton": "Accorder l'accès",
|
||||||
"requestSuccessMicrophone": "Accès au microphone demandé",
|
"requestSuccessMicrophone": "Accès au microphone demandé",
|
||||||
"requestSuccessCamera": "Accès à la caméra 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": {
|
"traffic": {
|
||||||
"title": "Détails du trafic",
|
"title": "Détails du trafic",
|
||||||
@@ -1497,13 +1519,18 @@
|
|||||||
"syncTooltipSyncing": "Synchronisation...",
|
"syncTooltipSyncing": "Synchronisation...",
|
||||||
"syncTooltipSyncedAt": "Synchronisé {{time}}",
|
"syncTooltipSyncedAt": "Synchronisé {{time}}",
|
||||||
"syncTooltipSynced": "Synchronisé",
|
"syncTooltipSynced": "Synchronisé",
|
||||||
"syncTooltipWaiting": "En attente de sync",
|
"syncTooltipWaiting": "En attente de synchronisation",
|
||||||
"syncTooltipErrorWith": "Erreur de sync : {{error}}",
|
"syncTooltipErrorWith": "Erreur de sync : {{error}}",
|
||||||
"syncTooltipError": "Erreur de sync",
|
"syncTooltipError": "Erreur de synchronisation",
|
||||||
"syncTooltipNotSynced": "Non synchronisé",
|
"syncTooltipNotSynced": "Non synchronisé",
|
||||||
"noTags": "Aucune étiquette",
|
"noTags": "Aucune étiquette",
|
||||||
"syncTooltipCloseToSync": "Fermez le profil pour synchroniser",
|
"syncTooltipCloseToSync": "Fermez le profil pour synchroniser",
|
||||||
"syncTooltipDisabledWithLast": "Sync désactivée, dernière sync {{time}}"
|
"syncTooltipDisabledWithLast": "Sync désactivée, dernière sync {{time}}",
|
||||||
|
"addTagsPlaceholder": "Ajouter des étiquettes",
|
||||||
|
"tagsHeader": "Étiquettes",
|
||||||
|
"noteHeader": "Note",
|
||||||
|
"vpnsHeading": "VPN",
|
||||||
|
"createByCountryHeading": "Créer par pays"
|
||||||
},
|
},
|
||||||
"releaseTypeSelector": {
|
"releaseTypeSelector": {
|
||||||
"noReleaseTypes": "Aucun type de version disponible.",
|
"noReleaseTypes": "Aucun type de version disponible.",
|
||||||
@@ -1521,7 +1548,14 @@
|
|||||||
"appUpdate": {
|
"appUpdate": {
|
||||||
"toast": {
|
"toast": {
|
||||||
"updateFailed": "Échec de la mise à jour de Donut Browser",
|
"updateFailed": "Échec de la mise à jour de Donut Browser",
|
||||||
"restartFailed": "Échec du redémarrage"
|
"restartFailed": "Échec du redémarrage",
|
||||||
|
"updateReady": "Mise à jour prête, redémarrer pour appliquer",
|
||||||
|
"manualDownloadRequired": "Téléchargement manuel requis",
|
||||||
|
"restartNow": "Redémarrer maintenant",
|
||||||
|
"viewRelease": "Voir la version",
|
||||||
|
"later": "Plus tard",
|
||||||
|
"uploading": "Envoi",
|
||||||
|
"downloading": "Téléchargement"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"browserDownload": {
|
"browserDownload": {
|
||||||
@@ -1532,7 +1566,10 @@
|
|||||||
"downloadFailed": "Échec du téléchargement de {{browser}} {{version}}",
|
"downloadFailed": "Échec du téléchargement de {{browser}} {{version}}",
|
||||||
"calculating": "calcul en cours...",
|
"calculating": "calcul en cours...",
|
||||||
"extractionFailed": "{{browser}} {{version}} : échec de l’extraction",
|
"extractionFailed": "{{browser}} {{version}} : échec de l’extraction",
|
||||||
"extractionFailedDescription": "Le fichier corrompu a été supprimé. Il sera retéléchargé lors de la prochaine tentative."
|
"extractionFailedDescription": "Le fichier corrompu a été supprimé. Il sera retéléchargé lors de la prochaine tentative.",
|
||||||
|
"extracting": "Extraction des fichiers du navigateur... Ne fermez pas l'application.",
|
||||||
|
"verifying": "Vérification des fichiers du navigateur...",
|
||||||
|
"downloadingRolling": "Téléchargement de la version rolling release..."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"versionUpdater": {
|
"versionUpdater": {
|
||||||
|
|||||||
+64
-27
@@ -60,7 +60,8 @@
|
|||||||
"optional": "任意",
|
"optional": "任意",
|
||||||
"required": "必須",
|
"required": "必須",
|
||||||
"unknownProfile": "不明",
|
"unknownProfile": "不明",
|
||||||
"mode": "モード"
|
"mode": "モード",
|
||||||
|
"never": "一度もありません"
|
||||||
},
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"days": "日",
|
"days": "日",
|
||||||
@@ -72,7 +73,11 @@
|
|||||||
"aria": {
|
"aria": {
|
||||||
"selectAll": "すべて選択",
|
"selectAll": "すべて選択",
|
||||||
"selectRow": "行を選択",
|
"selectRow": "行を選択",
|
||||||
"selectProfile": "プロファイルを選択"
|
"selectProfile": "プロファイルを選択",
|
||||||
|
"copy": "クリップボードにコピー",
|
||||||
|
"copied": "コピーしました",
|
||||||
|
"showToken": "トークンを表示",
|
||||||
|
"hideToken": "トークンを非表示"
|
||||||
},
|
},
|
||||||
"keys": {
|
"keys": {
|
||||||
"escape": "Esc"
|
"escape": "Esc"
|
||||||
@@ -87,7 +92,11 @@
|
|||||||
"title": "コマンドパレット",
|
"title": "コマンドパレット",
|
||||||
"description": "実行するコマンドを検索..."
|
"description": "実行するコマンドを検索..."
|
||||||
},
|
},
|
||||||
"noResults": "結果が見つかりません。"
|
"noResults": "結果が見つかりません。",
|
||||||
|
"srOnly": {
|
||||||
|
"copy": "コピー",
|
||||||
|
"copied": "コピーしました"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "設定",
|
"title": "設定",
|
||||||
@@ -152,7 +161,7 @@
|
|||||||
"commercial": {
|
"commercial": {
|
||||||
"title": "商用ライセンス",
|
"title": "商用ライセンス",
|
||||||
"trialActive": "トライアル: 残り {{days}} 日 {{hours}} 時間",
|
"trialActive": "トライアル: 残り {{days}} 日 {{hours}} 時間",
|
||||||
"trialActiveDescription": "トライアル期間中は商用利用が無料です",
|
"trialActiveDescription": "トライアル期間中は商用利用が無料です。期間が終了してもすべての機能はそのまま使用できます — 個人利用は引き続き無料で、商用利用のみライセンスが必要になります。",
|
||||||
"trialExpired": "トライアル期限切れ",
|
"trialExpired": "トライアル期限切れ",
|
||||||
"trialExpiredDescription": "個人利用は引き続き無料です。商用利用にはライセンスが必要です。"
|
"trialExpiredDescription": "個人利用は引き続き無料です。商用利用にはライセンスが必要です。"
|
||||||
},
|
},
|
||||||
@@ -196,7 +205,8 @@
|
|||||||
"group": "グループ",
|
"group": "グループ",
|
||||||
"proxy": "プロキシ / VPN",
|
"proxy": "プロキシ / VPN",
|
||||||
"lastLaunch": "最終起動",
|
"lastLaunch": "最終起動",
|
||||||
"empty": "プロファイルが見つかりません。"
|
"empty": "プロファイルが見つかりません。",
|
||||||
|
"notSelected": "未選択"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"launch": "起動",
|
"launch": "起動",
|
||||||
@@ -488,7 +498,8 @@
|
|||||||
"deleteGroupAndProfiles": "グループとプロファイルを削除",
|
"deleteGroupAndProfiles": "グループとプロファイルを削除",
|
||||||
"loadProfilesFailed": "プロファイルの読み込みに失敗しました",
|
"loadProfilesFailed": "プロファイルの読み込みに失敗しました",
|
||||||
"unknownGroup": "不明なグループ",
|
"unknownGroup": "不明なグループ",
|
||||||
"profileGroupsAriaLabel": "プロファイルグループ"
|
"profileGroupsAriaLabel": "プロファイルグループ",
|
||||||
|
"loading": "グループを読み込み中..."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"mode": {
|
"mode": {
|
||||||
@@ -631,7 +642,8 @@
|
|||||||
"mcpAcceptTermsFirst": "(設定で先に Wayfern の規約に同意してください)",
|
"mcpAcceptTermsFirst": "(設定で先に Wayfern の規約に同意してください)",
|
||||||
"mcpStarted": "MCP サーバーをポート {{port}} で起動しました",
|
"mcpStarted": "MCP サーバーをポート {{port}} で起動しました",
|
||||||
"mcpStopped": "MCP サーバーを停止しました",
|
"mcpStopped": "MCP サーバーを停止しました",
|
||||||
"mcpToggleFailed": "MCP サーバーの切り替えに失敗しました"
|
"mcpToggleFailed": "MCP サーバーの切り替えに失敗しました",
|
||||||
|
"openSettings": "統合設定を開く"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "プロファイルをインポート",
|
"title": "プロファイルをインポート",
|
||||||
@@ -711,6 +723,10 @@
|
|||||||
"webrtc": "WebRTCをブロック",
|
"webrtc": "WebRTCをブロック",
|
||||||
"webgl": "WebGLをブロック"
|
"webgl": "WebGLをブロック"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"shared": {
|
||||||
|
"browserBehavior": "ブラウザの動作",
|
||||||
|
"allowAddonsOpenTabs": "ブラウザアドオンが新しいタブを自動的に開くことを許可"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cookies": {
|
"cookies": {
|
||||||
@@ -875,7 +891,8 @@
|
|||||||
"loadProxiesFailed": "プロキシの読み込みに失敗しました: {{error}}",
|
"loadProxiesFailed": "プロキシの読み込みに失敗しました: {{error}}",
|
||||||
"setupProxyListenersFailed": "プロキシイベントリスナーの設定に失敗しました: {{error}}",
|
"setupProxyListenersFailed": "プロキシイベントリスナーの設定に失敗しました: {{error}}",
|
||||||
"loadVpnConfigsFailed": "VPN設定の読み込みに失敗しました: {{error}}",
|
"loadVpnConfigsFailed": "VPN設定の読み込みに失敗しました: {{error}}",
|
||||||
"setupVpnListenersFailed": "VPNイベントリスナーの設定に失敗しました: {{error}}"
|
"setupVpnListenersFailed": "VPNイベントリスナーの設定に失敗しました: {{error}}",
|
||||||
|
"themeNotFound": "Tokyo Night テーマが見つかりません"
|
||||||
},
|
},
|
||||||
"browser": {
|
"browser": {
|
||||||
"camoufox": "Camoufox",
|
"camoufox": "Camoufox",
|
||||||
@@ -901,7 +918,7 @@
|
|||||||
"blockWebRTC": "WebRTCをブロック",
|
"blockWebRTC": "WebRTCをブロック",
|
||||||
"blockWebGL": "WebGLをブロック",
|
"blockWebGL": "WebGLをブロック",
|
||||||
"navigatorProperties": "Navigatorプロパティ",
|
"navigatorProperties": "Navigatorプロパティ",
|
||||||
"userAgent": "User Agent",
|
"userAgent": "ユーザーエージェント",
|
||||||
"userAgentAndPlatform": "User Agent & Platform",
|
"userAgentAndPlatform": "User Agent & Platform",
|
||||||
"platform": "Platform",
|
"platform": "Platform",
|
||||||
"platformVersion": "Platform Version",
|
"platformVersion": "Platform Version",
|
||||||
@@ -909,7 +926,7 @@
|
|||||||
"osCpu": "OS CPU",
|
"osCpu": "OS CPU",
|
||||||
"hardwareConcurrency": "Hardware Concurrency",
|
"hardwareConcurrency": "Hardware Concurrency",
|
||||||
"maxTouchPoints": "最大タッチポイント数",
|
"maxTouchPoints": "最大タッチポイント数",
|
||||||
"doNotTrack": "Do Not Track",
|
"doNotTrack": "追跡しない",
|
||||||
"selectDntPlaceholder": "DNT値を選択",
|
"selectDntPlaceholder": "DNT値を選択",
|
||||||
"dntAllowed": "0(トラッキング許可)",
|
"dntAllowed": "0(トラッキング許可)",
|
||||||
"dntNotAllowed": "1(トラッキング不許可)",
|
"dntNotAllowed": "1(トラッキング不許可)",
|
||||||
@@ -931,8 +948,8 @@
|
|||||||
"outerHeight": "外側の高さ",
|
"outerHeight": "外側の高さ",
|
||||||
"innerWidth": "内側の幅",
|
"innerWidth": "内側の幅",
|
||||||
"innerHeight": "内側の高さ",
|
"innerHeight": "内側の高さ",
|
||||||
"screenX": "Screen X",
|
"screenX": "画面 X",
|
||||||
"screenY": "Screen Y",
|
"screenY": "画面 Y",
|
||||||
"geolocation": "ジオロケーション",
|
"geolocation": "ジオロケーション",
|
||||||
"timezoneAndGeolocation": "タイムゾーンとジオロケーション",
|
"timezoneAndGeolocation": "タイムゾーンとジオロケーション",
|
||||||
"timezoneGeolocationDescription": "これらの値はブラウザのタイムゾーンとジオロケーションAPIを上書きします。",
|
"timezoneGeolocationDescription": "これらの値はブラウザのタイムゾーンとジオロケーションAPIを上書きします。",
|
||||||
@@ -946,15 +963,15 @@
|
|||||||
"region": "地域",
|
"region": "地域",
|
||||||
"script": "スクリプト",
|
"script": "スクリプト",
|
||||||
"webglProperties": "WebGLプロパティ",
|
"webglProperties": "WebGLプロパティ",
|
||||||
"webglVendor": "WebGL Vendor",
|
"webglVendor": "WebGL ベンダー",
|
||||||
"webglRenderer": "WebGL Renderer",
|
"webglRenderer": "WebGL レンダラー",
|
||||||
"webglParameters": "WebGLパラメータ",
|
"webglParameters": "WebGLパラメータ",
|
||||||
"webglParametersJson": "WebGLパラメータ (JSON)",
|
"webglParametersJson": "WebGLパラメータ (JSON)",
|
||||||
"webgl2Parameters": "WebGL2パラメータ",
|
"webgl2Parameters": "WebGL2パラメータ",
|
||||||
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
|
"webglShaderPrecisionFormats": "WebGL シェーダー精度フォーマット",
|
||||||
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
|
"webgl2ShaderPrecisionFormats": "WebGL2 シェーダー精度フォーマット",
|
||||||
"canvasFingerprint": "Canvas Fingerprint",
|
"canvasFingerprint": "Canvas Fingerprint",
|
||||||
"canvasNoiseSeed": "Canvas Noise Seed",
|
"canvasNoiseSeed": "Canvas ノイズシード",
|
||||||
"canvasNoiseSeedDescription": "このシードは一貫性がありながらもユニークなCanvasフィンガープリントを生成するために使用されます。各プロファイルには異なるシードを設定してください。",
|
"canvasNoiseSeedDescription": "このシードは一貫性がありながらもユニークなCanvasフィンガープリントを生成するために使用されます。各プロファイルには異なるシードを設定してください。",
|
||||||
"fonts": "フォント",
|
"fonts": "フォント",
|
||||||
"fontsJson": "フォント (JSON配列)",
|
"fontsJson": "フォント (JSON配列)",
|
||||||
@@ -975,8 +992,8 @@
|
|||||||
"maxChannelCount": "最大チャンネル数",
|
"maxChannelCount": "最大チャンネル数",
|
||||||
"vendorInfo": "ベンダー情報",
|
"vendorInfo": "ベンダー情報",
|
||||||
"vendor": "ベンダー",
|
"vendor": "ベンダー",
|
||||||
"vendorSub": "Vendor Sub",
|
"vendorSub": "ベンダーサブ",
|
||||||
"productSub": "Product Sub",
|
"productSub": "プロダクトサブ",
|
||||||
"brand": "ブランド",
|
"brand": "ブランド",
|
||||||
"brandVersion": "ブランドバージョン",
|
"brandVersion": "ブランドバージョン",
|
||||||
"proFeature": "これはPro機能です",
|
"proFeature": "これはPro機能です",
|
||||||
@@ -1124,7 +1141,9 @@
|
|||||||
"syncEnabled": "同期が有効",
|
"syncEnabled": "同期が有効",
|
||||||
"syncDisabled": "同期が無効",
|
"syncDisabled": "同期が無効",
|
||||||
"syncEnableTooltip": "同期を有効にする",
|
"syncEnableTooltip": "同期を有効にする",
|
||||||
"syncDisableTooltip": "同期を無効にする"
|
"syncDisableTooltip": "同期を無効にする",
|
||||||
|
"loadGroupsFailed": "拡張機能グループの読み込みに失敗しました",
|
||||||
|
"assignGroupFailed": "拡張機能グループの割り当てに失敗しました"
|
||||||
},
|
},
|
||||||
"pro": {
|
"pro": {
|
||||||
"badge": "PRO",
|
"badge": "PRO",
|
||||||
@@ -1256,12 +1275,11 @@
|
|||||||
"importedSuccess": "プロファイル「{{name}}」をインポートしました",
|
"importedSuccess": "プロファイル「{{name}}」をインポートしました",
|
||||||
"notInstalled": "{{browser}} はインストールされていません。メインウィンドウから {{browser}} をダウンロードしてからもう一度インポートしてください。",
|
"notInstalled": "{{browser}} はインストールされていません。メインウィンドウから {{browser}} をダウンロードしてからもう一度インポートしてください。",
|
||||||
"importFailed": "プロファイルのインポートに失敗しました: {{error}}",
|
"importFailed": "プロファイルのインポートに失敗しました: {{error}}",
|
||||||
"importedAsPrefix": "このプロファイルは次のプロファイルとしてインポートされます:",
|
|
||||||
"importedAsSuffix": "",
|
|
||||||
"proxyOptional": "プロキシ (任意)",
|
"proxyOptional": "プロキシ (任意)",
|
||||||
"noProxy": "プロキシなし",
|
"noProxy": "プロキシなし",
|
||||||
"nextButton": "次へ",
|
"nextButton": "次へ",
|
||||||
"importButton": "インポート"
|
"importButton": "インポート",
|
||||||
|
"importedAs": "このプロファイルは {{browser}} プロファイルとしてインポートされます。"
|
||||||
},
|
},
|
||||||
"syncTooltips": {
|
"syncTooltips": {
|
||||||
"syncing": "同期中...",
|
"syncing": "同期中...",
|
||||||
@@ -1424,7 +1442,11 @@
|
|||||||
"grantAccessButton": "アクセスを許可",
|
"grantAccessButton": "アクセスを許可",
|
||||||
"requestSuccessMicrophone": "マイクアクセスをリクエストしました",
|
"requestSuccessMicrophone": "マイクアクセスをリクエストしました",
|
||||||
"requestSuccessCamera": "カメラアクセスをリクエストしました",
|
"requestSuccessCamera": "カメラアクセスをリクエストしました",
|
||||||
"requestFailed": "許可のリクエストに失敗しました"
|
"requestFailed": "許可のリクエストに失敗しました",
|
||||||
|
"stillNotGrantedMicrophone": "マイクへのアクセスはまだ許可されていません。システム設定 → プライバシーとセキュリティ → マイク で手動で有効にする必要があるかもしれません。",
|
||||||
|
"stillNotGrantedCamera": "カメラへのアクセスはまだ許可されていません。システム設定 → プライバシーとセキュリティ → カメラ で手動で有効にする必要があるかもしれません。",
|
||||||
|
"grantedToastMicrophone": "マイクへのアクセスが許可されました",
|
||||||
|
"grantedToastCamera": "カメラへのアクセスが許可されました"
|
||||||
},
|
},
|
||||||
"traffic": {
|
"traffic": {
|
||||||
"title": "トラフィックの詳細",
|
"title": "トラフィックの詳細",
|
||||||
@@ -1503,7 +1525,12 @@
|
|||||||
"syncTooltipNotSynced": "未同期",
|
"syncTooltipNotSynced": "未同期",
|
||||||
"noTags": "タグなし",
|
"noTags": "タグなし",
|
||||||
"syncTooltipCloseToSync": "プロファイルを閉じて同期",
|
"syncTooltipCloseToSync": "プロファイルを閉じて同期",
|
||||||
"syncTooltipDisabledWithLast": "同期無効、最終同期 {{time}}"
|
"syncTooltipDisabledWithLast": "同期無効、最終同期 {{time}}",
|
||||||
|
"addTagsPlaceholder": "タグを追加",
|
||||||
|
"tagsHeader": "タグ",
|
||||||
|
"noteHeader": "メモ",
|
||||||
|
"vpnsHeading": "VPN",
|
||||||
|
"createByCountryHeading": "国別に作成"
|
||||||
},
|
},
|
||||||
"releaseTypeSelector": {
|
"releaseTypeSelector": {
|
||||||
"noReleaseTypes": "利用可能なリリースタイプがありません。",
|
"noReleaseTypes": "利用可能なリリースタイプがありません。",
|
||||||
@@ -1521,7 +1548,14 @@
|
|||||||
"appUpdate": {
|
"appUpdate": {
|
||||||
"toast": {
|
"toast": {
|
||||||
"updateFailed": "Donut Browser の更新に失敗しました",
|
"updateFailed": "Donut Browser の更新に失敗しました",
|
||||||
"restartFailed": "再起動に失敗しました"
|
"restartFailed": "再起動に失敗しました",
|
||||||
|
"updateReady": "アップデートの準備完了。再起動して適用",
|
||||||
|
"manualDownloadRequired": "手動ダウンロードが必要です",
|
||||||
|
"restartNow": "今すぐ再起動",
|
||||||
|
"viewRelease": "リリースを見る",
|
||||||
|
"later": "後で",
|
||||||
|
"uploading": "アップロード中",
|
||||||
|
"downloading": "ダウンロード中"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"browserDownload": {
|
"browserDownload": {
|
||||||
@@ -1532,7 +1566,10 @@
|
|||||||
"downloadFailed": "{{browser}} {{version}} のダウンロードに失敗しました",
|
"downloadFailed": "{{browser}} {{version}} のダウンロードに失敗しました",
|
||||||
"calculating": "計算中...",
|
"calculating": "計算中...",
|
||||||
"extractionFailed": "{{browser}} {{version}}: 展開に失敗しました",
|
"extractionFailed": "{{browser}} {{version}}: 展開に失敗しました",
|
||||||
"extractionFailedDescription": "破損したファイルは削除されました。次回の試行時に再ダウンロードされます。"
|
"extractionFailedDescription": "破損したファイルは削除されました。次回の試行時に再ダウンロードされます。",
|
||||||
|
"extracting": "ブラウザファイルを展開中... アプリを閉じないでください。",
|
||||||
|
"verifying": "ブラウザファイルを検証中...",
|
||||||
|
"downloadingRolling": "ローリングリリースビルドをダウンロード中..."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"versionUpdater": {
|
"versionUpdater": {
|
||||||
|
|||||||
+65
-28
@@ -60,7 +60,8 @@
|
|||||||
"optional": "Opcional",
|
"optional": "Opcional",
|
||||||
"required": "Obrigatório",
|
"required": "Obrigatório",
|
||||||
"unknownProfile": "Desconhecido",
|
"unknownProfile": "Desconhecido",
|
||||||
"mode": "Modo"
|
"mode": "Modo",
|
||||||
|
"never": "Nunca"
|
||||||
},
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"days": "dias",
|
"days": "dias",
|
||||||
@@ -72,7 +73,11 @@
|
|||||||
"aria": {
|
"aria": {
|
||||||
"selectAll": "Selecionar tudo",
|
"selectAll": "Selecionar tudo",
|
||||||
"selectRow": "Selecionar linha",
|
"selectRow": "Selecionar linha",
|
||||||
"selectProfile": "Selecionar perfil"
|
"selectProfile": "Selecionar perfil",
|
||||||
|
"copy": "Copiar para a área de transferência",
|
||||||
|
"copied": "Copiado",
|
||||||
|
"showToken": "Mostrar token",
|
||||||
|
"hideToken": "Ocultar token"
|
||||||
},
|
},
|
||||||
"keys": {
|
"keys": {
|
||||||
"escape": "Esc"
|
"escape": "Esc"
|
||||||
@@ -87,7 +92,11 @@
|
|||||||
"title": "Paleta de comandos",
|
"title": "Paleta de comandos",
|
||||||
"description": "Pesquise um comando para executar..."
|
"description": "Pesquise um comando para executar..."
|
||||||
},
|
},
|
||||||
"noResults": "Nenhum resultado encontrado."
|
"noResults": "Nenhum resultado encontrado.",
|
||||||
|
"srOnly": {
|
||||||
|
"copy": "Copiar",
|
||||||
|
"copied": "Copiado"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Configurações",
|
"title": "Configurações",
|
||||||
@@ -152,7 +161,7 @@
|
|||||||
"commercial": {
|
"commercial": {
|
||||||
"title": "Licença Comercial",
|
"title": "Licença Comercial",
|
||||||
"trialActive": "Teste: {{days}} dias, {{hours}} horas restantes",
|
"trialActive": "Teste: {{days}} dias, {{hours}} horas restantes",
|
||||||
"trialActiveDescription": "O uso comercial é gratuito durante o período de teste",
|
"trialActiveDescription": "O uso comercial é gratuito durante o teste. Após o término, todos os recursos continuam funcionando — o uso pessoal permanece gratuito, apenas o uso comercial exigirá uma licença.",
|
||||||
"trialExpired": "Teste expirado",
|
"trialExpired": "Teste expirado",
|
||||||
"trialExpiredDescription": "O uso pessoal continua gratuito. O uso comercial requer uma licença."
|
"trialExpiredDescription": "O uso pessoal continua gratuito. O uso comercial requer uma licença."
|
||||||
},
|
},
|
||||||
@@ -196,7 +205,8 @@
|
|||||||
"group": "Grupo",
|
"group": "Grupo",
|
||||||
"proxy": "Proxy / VPN",
|
"proxy": "Proxy / VPN",
|
||||||
"lastLaunch": "Último Início",
|
"lastLaunch": "Último Início",
|
||||||
"empty": "Nenhum perfil encontrado."
|
"empty": "Nenhum perfil encontrado.",
|
||||||
|
"notSelected": "Não selecionado"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"launch": "Iniciar",
|
"launch": "Iniciar",
|
||||||
@@ -488,7 +498,8 @@
|
|||||||
"deleteGroupAndProfiles": "Excluir Grupo e Perfis",
|
"deleteGroupAndProfiles": "Excluir Grupo e Perfis",
|
||||||
"loadProfilesFailed": "Falha ao carregar os perfis",
|
"loadProfilesFailed": "Falha ao carregar os perfis",
|
||||||
"unknownGroup": "Grupo desconhecido",
|
"unknownGroup": "Grupo desconhecido",
|
||||||
"profileGroupsAriaLabel": "Grupos de perfis"
|
"profileGroupsAriaLabel": "Grupos de perfis",
|
||||||
|
"loading": "Carregando grupos..."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"mode": {
|
"mode": {
|
||||||
@@ -631,7 +642,8 @@
|
|||||||
"mcpAcceptTermsFirst": "(Aceite primeiro os termos da Wayfern nas Configurações)",
|
"mcpAcceptTermsFirst": "(Aceite primeiro os termos da Wayfern nas Configurações)",
|
||||||
"mcpStarted": "Servidor MCP iniciado na porta {{port}}",
|
"mcpStarted": "Servidor MCP iniciado na porta {{port}}",
|
||||||
"mcpStopped": "Servidor MCP parado",
|
"mcpStopped": "Servidor MCP parado",
|
||||||
"mcpToggleFailed": "Falha ao alternar o servidor MCP"
|
"mcpToggleFailed": "Falha ao alternar o servidor MCP",
|
||||||
|
"openSettings": "Abrir configurações de integrações"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Importar Perfil",
|
"title": "Importar Perfil",
|
||||||
@@ -711,6 +723,10 @@
|
|||||||
"webrtc": "Bloquear WebRTC",
|
"webrtc": "Bloquear WebRTC",
|
||||||
"webgl": "Bloquear WebGL"
|
"webgl": "Bloquear WebGL"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"shared": {
|
||||||
|
"browserBehavior": "Comportamento do navegador",
|
||||||
|
"allowAddonsOpenTabs": "Permitir que extensões abram novas abas automaticamente"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cookies": {
|
"cookies": {
|
||||||
@@ -875,7 +891,8 @@
|
|||||||
"loadProxiesFailed": "Falha ao carregar os proxies: {{error}}",
|
"loadProxiesFailed": "Falha ao carregar os proxies: {{error}}",
|
||||||
"setupProxyListenersFailed": "Falha ao configurar os listeners de eventos de proxies: {{error}}",
|
"setupProxyListenersFailed": "Falha ao configurar os listeners de eventos de proxies: {{error}}",
|
||||||
"loadVpnConfigsFailed": "Falha ao carregar as configurações de VPN: {{error}}",
|
"loadVpnConfigsFailed": "Falha ao carregar as configurações de VPN: {{error}}",
|
||||||
"setupVpnListenersFailed": "Falha ao configurar os listeners de eventos de VPN: {{error}}"
|
"setupVpnListenersFailed": "Falha ao configurar os listeners de eventos de VPN: {{error}}",
|
||||||
|
"themeNotFound": "Tema Tokyo Night não encontrado"
|
||||||
},
|
},
|
||||||
"browser": {
|
"browser": {
|
||||||
"camoufox": "Camoufox",
|
"camoufox": "Camoufox",
|
||||||
@@ -901,15 +918,15 @@
|
|||||||
"blockWebRTC": "Bloquear WebRTC",
|
"blockWebRTC": "Bloquear WebRTC",
|
||||||
"blockWebGL": "Bloquear WebGL",
|
"blockWebGL": "Bloquear WebGL",
|
||||||
"navigatorProperties": "Propriedades do Navigator",
|
"navigatorProperties": "Propriedades do Navigator",
|
||||||
"userAgent": "User Agent",
|
"userAgent": "Agente do usuário",
|
||||||
"userAgentAndPlatform": "User Agent & Platform",
|
"userAgentAndPlatform": "User Agent & Platform",
|
||||||
"platform": "Platform",
|
"platform": "Platform",
|
||||||
"platformVersion": "Platform Version",
|
"platformVersion": "Platform Version",
|
||||||
"appVersion": "App Version",
|
"appVersion": "App Version",
|
||||||
"osCpu": "OS CPU",
|
"osCpu": "CPU do SO",
|
||||||
"hardwareConcurrency": "Hardware Concurrency",
|
"hardwareConcurrency": "Hardware Concurrency",
|
||||||
"maxTouchPoints": "Pontos de Toque Máximos",
|
"maxTouchPoints": "Pontos de Toque Máximos",
|
||||||
"doNotTrack": "Do Not Track",
|
"doNotTrack": "Não rastrear",
|
||||||
"selectDntPlaceholder": "Selecionar valor DNT",
|
"selectDntPlaceholder": "Selecionar valor DNT",
|
||||||
"dntAllowed": "0 (rastreamento permitido)",
|
"dntAllowed": "0 (rastreamento permitido)",
|
||||||
"dntNotAllowed": "1 (rastreamento não permitido)",
|
"dntNotAllowed": "1 (rastreamento não permitido)",
|
||||||
@@ -931,8 +948,8 @@
|
|||||||
"outerHeight": "Altura Externa",
|
"outerHeight": "Altura Externa",
|
||||||
"innerWidth": "Largura Interna",
|
"innerWidth": "Largura Interna",
|
||||||
"innerHeight": "Altura Interna",
|
"innerHeight": "Altura Interna",
|
||||||
"screenX": "Screen X",
|
"screenX": "Tela X",
|
||||||
"screenY": "Screen Y",
|
"screenY": "Tela Y",
|
||||||
"geolocation": "Geolocalização",
|
"geolocation": "Geolocalização",
|
||||||
"timezoneAndGeolocation": "Fuso Horário e Geolocalização",
|
"timezoneAndGeolocation": "Fuso Horário e Geolocalização",
|
||||||
"timezoneGeolocationDescription": "Estes valores substituem as APIs de fuso horário e geolocalização do navegador.",
|
"timezoneGeolocationDescription": "Estes valores substituem as APIs de fuso horário e geolocalização do navegador.",
|
||||||
@@ -946,15 +963,15 @@
|
|||||||
"region": "Região",
|
"region": "Região",
|
||||||
"script": "Script",
|
"script": "Script",
|
||||||
"webglProperties": "Propriedades WebGL",
|
"webglProperties": "Propriedades WebGL",
|
||||||
"webglVendor": "WebGL Vendor",
|
"webglVendor": "Fornecedor WebGL",
|
||||||
"webglRenderer": "WebGL Renderer",
|
"webglRenderer": "Renderizador WebGL",
|
||||||
"webglParameters": "Parâmetros WebGL",
|
"webglParameters": "Parâmetros WebGL",
|
||||||
"webglParametersJson": "Parâmetros WebGL (JSON)",
|
"webglParametersJson": "Parâmetros WebGL (JSON)",
|
||||||
"webgl2Parameters": "Parâmetros WebGL2",
|
"webgl2Parameters": "Parâmetros WebGL2",
|
||||||
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
|
"webglShaderPrecisionFormats": "Formatos de precisão de shader WebGL",
|
||||||
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
|
"webgl2ShaderPrecisionFormats": "Formatos de precisão de shader WebGL2",
|
||||||
"canvasFingerprint": "Canvas Fingerprint",
|
"canvasFingerprint": "Canvas Fingerprint",
|
||||||
"canvasNoiseSeed": "Canvas Noise Seed",
|
"canvasNoiseSeed": "Semente de ruído Canvas",
|
||||||
"canvasNoiseSeedDescription": "Este seed é usado para gerar uma impressão digital Canvas consistente, mas única. Cada perfil deve ter um seed diferente.",
|
"canvasNoiseSeedDescription": "Este seed é usado para gerar uma impressão digital Canvas consistente, mas única. Cada perfil deve ter um seed diferente.",
|
||||||
"fonts": "Fontes",
|
"fonts": "Fontes",
|
||||||
"fontsJson": "Fontes (JSON array)",
|
"fontsJson": "Fontes (JSON array)",
|
||||||
@@ -975,8 +992,8 @@
|
|||||||
"maxChannelCount": "Contagem Máxima de Canais",
|
"maxChannelCount": "Contagem Máxima de Canais",
|
||||||
"vendorInfo": "Informações do Fabricante",
|
"vendorInfo": "Informações do Fabricante",
|
||||||
"vendor": "Fabricante",
|
"vendor": "Fabricante",
|
||||||
"vendorSub": "Vendor Sub",
|
"vendorSub": "Fornecedor Sub",
|
||||||
"productSub": "Product Sub",
|
"productSub": "Produto Sub",
|
||||||
"brand": "Marca",
|
"brand": "Marca",
|
||||||
"brandVersion": "Versão da Marca",
|
"brandVersion": "Versão da Marca",
|
||||||
"proFeature": "Este é um recurso Pro",
|
"proFeature": "Este é um recurso Pro",
|
||||||
@@ -1124,7 +1141,9 @@
|
|||||||
"syncEnabled": "Sincronização ativada",
|
"syncEnabled": "Sincronização ativada",
|
||||||
"syncDisabled": "Sincronização desativada",
|
"syncDisabled": "Sincronização desativada",
|
||||||
"syncEnableTooltip": "Ativar sincronização",
|
"syncEnableTooltip": "Ativar sincronização",
|
||||||
"syncDisableTooltip": "Desativar sincronização"
|
"syncDisableTooltip": "Desativar sincronização",
|
||||||
|
"loadGroupsFailed": "Falha ao carregar grupos de extensões",
|
||||||
|
"assignGroupFailed": "Falha ao atribuir grupo de extensões"
|
||||||
},
|
},
|
||||||
"pro": {
|
"pro": {
|
||||||
"badge": "PRO",
|
"badge": "PRO",
|
||||||
@@ -1256,12 +1275,11 @@
|
|||||||
"importedSuccess": "Perfil \"{{name}}\" importado com sucesso",
|
"importedSuccess": "Perfil \"{{name}}\" importado com sucesso",
|
||||||
"notInstalled": "{{browser}} não está instalado. Baixe {{browser}} primeiro pela janela principal e tente importar novamente.",
|
"notInstalled": "{{browser}} não está instalado. Baixe {{browser}} primeiro pela janela principal e tente importar novamente.",
|
||||||
"importFailed": "Falha ao importar perfil: {{error}}",
|
"importFailed": "Falha ao importar perfil: {{error}}",
|
||||||
"importedAsPrefix": "Este perfil será importado como um perfil",
|
|
||||||
"importedAsSuffix": ".",
|
|
||||||
"proxyOptional": "Proxy (Opcional)",
|
"proxyOptional": "Proxy (Opcional)",
|
||||||
"noProxy": "Sem proxy",
|
"noProxy": "Sem proxy",
|
||||||
"nextButton": "Próximo",
|
"nextButton": "Próximo",
|
||||||
"importButton": "Importar"
|
"importButton": "Importar",
|
||||||
|
"importedAs": "Este perfil será importado como um perfil {{browser}}."
|
||||||
},
|
},
|
||||||
"syncTooltips": {
|
"syncTooltips": {
|
||||||
"syncing": "Sincronizando...",
|
"syncing": "Sincronizando...",
|
||||||
@@ -1424,7 +1442,11 @@
|
|||||||
"grantAccessButton": "Conceder acesso",
|
"grantAccessButton": "Conceder acesso",
|
||||||
"requestSuccessMicrophone": "Acesso ao microfone solicitado",
|
"requestSuccessMicrophone": "Acesso ao microfone solicitado",
|
||||||
"requestSuccessCamera": "Acesso à câmera 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": {
|
"traffic": {
|
||||||
"title": "Detalhes do tráfego",
|
"title": "Detalhes do tráfego",
|
||||||
@@ -1503,7 +1525,12 @@
|
|||||||
"syncTooltipNotSynced": "Não sincronizado",
|
"syncTooltipNotSynced": "Não sincronizado",
|
||||||
"noTags": "Sem tags",
|
"noTags": "Sem tags",
|
||||||
"syncTooltipCloseToSync": "Feche o perfil para sincronizar",
|
"syncTooltipCloseToSync": "Feche o perfil para sincronizar",
|
||||||
"syncTooltipDisabledWithLast": "Sincronização desativada, última sincronização {{time}}"
|
"syncTooltipDisabledWithLast": "Sincronização desativada, última sincronização {{time}}",
|
||||||
|
"addTagsPlaceholder": "Adicionar etiquetas",
|
||||||
|
"tagsHeader": "Etiquetas",
|
||||||
|
"noteHeader": "Nota",
|
||||||
|
"vpnsHeading": "VPNs",
|
||||||
|
"createByCountryHeading": "Criar por país"
|
||||||
},
|
},
|
||||||
"releaseTypeSelector": {
|
"releaseTypeSelector": {
|
||||||
"noReleaseTypes": "Nenhum tipo de versão disponível.",
|
"noReleaseTypes": "Nenhum tipo de versão disponível.",
|
||||||
@@ -1521,7 +1548,14 @@
|
|||||||
"appUpdate": {
|
"appUpdate": {
|
||||||
"toast": {
|
"toast": {
|
||||||
"updateFailed": "Falha ao atualizar o Donut Browser",
|
"updateFailed": "Falha ao atualizar o Donut Browser",
|
||||||
"restartFailed": "Falha ao reiniciar"
|
"restartFailed": "Falha ao reiniciar",
|
||||||
|
"updateReady": "Atualização pronta, reinicie para aplicar",
|
||||||
|
"manualDownloadRequired": "Download manual necessário",
|
||||||
|
"restartNow": "Reiniciar agora",
|
||||||
|
"viewRelease": "Ver lançamento",
|
||||||
|
"later": "Mais tarde",
|
||||||
|
"uploading": "Enviando",
|
||||||
|
"downloading": "Baixando"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"browserDownload": {
|
"browserDownload": {
|
||||||
@@ -1532,7 +1566,10 @@
|
|||||||
"downloadFailed": "Falha ao baixar {{browser}} {{version}}",
|
"downloadFailed": "Falha ao baixar {{browser}} {{version}}",
|
||||||
"calculating": "calculando...",
|
"calculating": "calculando...",
|
||||||
"extractionFailed": "{{browser}} {{version}}: falha na extração",
|
"extractionFailed": "{{browser}} {{version}}: falha na extração",
|
||||||
"extractionFailedDescription": "O arquivo corrompido foi excluído. Será baixado novamente na próxima tentativa."
|
"extractionFailedDescription": "O arquivo corrompido foi excluído. Será baixado novamente na próxima tentativa.",
|
||||||
|
"extracting": "Extraindo arquivos do navegador... Não feche o aplicativo.",
|
||||||
|
"verifying": "Verificando arquivos do navegador...",
|
||||||
|
"downloadingRolling": "Baixando build rolling release..."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"versionUpdater": {
|
"versionUpdater": {
|
||||||
|
|||||||
+64
-27
@@ -60,7 +60,8 @@
|
|||||||
"optional": "Необязательно",
|
"optional": "Необязательно",
|
||||||
"required": "Обязательно",
|
"required": "Обязательно",
|
||||||
"unknownProfile": "Неизвестный",
|
"unknownProfile": "Неизвестный",
|
||||||
"mode": "Режим"
|
"mode": "Режим",
|
||||||
|
"never": "Никогда"
|
||||||
},
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"days": "дней",
|
"days": "дней",
|
||||||
@@ -72,7 +73,11 @@
|
|||||||
"aria": {
|
"aria": {
|
||||||
"selectAll": "Выбрать все",
|
"selectAll": "Выбрать все",
|
||||||
"selectRow": "Выбрать строку",
|
"selectRow": "Выбрать строку",
|
||||||
"selectProfile": "Выбрать профиль"
|
"selectProfile": "Выбрать профиль",
|
||||||
|
"copy": "Скопировать в буфер обмена",
|
||||||
|
"copied": "Скопировано",
|
||||||
|
"showToken": "Показать токен",
|
||||||
|
"hideToken": "Скрыть токен"
|
||||||
},
|
},
|
||||||
"keys": {
|
"keys": {
|
||||||
"escape": "Esc"
|
"escape": "Esc"
|
||||||
@@ -87,7 +92,11 @@
|
|||||||
"title": "Палитра команд",
|
"title": "Палитра команд",
|
||||||
"description": "Найдите команду для выполнения..."
|
"description": "Найдите команду для выполнения..."
|
||||||
},
|
},
|
||||||
"noResults": "Результаты не найдены."
|
"noResults": "Результаты не найдены.",
|
||||||
|
"srOnly": {
|
||||||
|
"copy": "Скопировать",
|
||||||
|
"copied": "Скопировано"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Настройки",
|
"title": "Настройки",
|
||||||
@@ -152,7 +161,7 @@
|
|||||||
"commercial": {
|
"commercial": {
|
||||||
"title": "Коммерческая лицензия",
|
"title": "Коммерческая лицензия",
|
||||||
"trialActive": "Пробный период: осталось {{days}} дней, {{hours}} часов",
|
"trialActive": "Пробный период: осталось {{days}} дней, {{hours}} часов",
|
||||||
"trialActiveDescription": "Коммерческое использование бесплатно в течение пробного периода",
|
"trialActiveDescription": "Коммерческое использование бесплатно в течение пробного периода. После его окончания все функции продолжают работать — личное использование остаётся бесплатным, и только для коммерческого использования потребуется лицензия.",
|
||||||
"trialExpired": "Пробный период истёк",
|
"trialExpired": "Пробный период истёк",
|
||||||
"trialExpiredDescription": "Личное использование остаётся бесплатным. Для коммерческого использования требуется лицензия."
|
"trialExpiredDescription": "Личное использование остаётся бесплатным. Для коммерческого использования требуется лицензия."
|
||||||
},
|
},
|
||||||
@@ -196,7 +205,8 @@
|
|||||||
"group": "Группа",
|
"group": "Группа",
|
||||||
"proxy": "Прокси / VPN",
|
"proxy": "Прокси / VPN",
|
||||||
"lastLaunch": "Последний запуск",
|
"lastLaunch": "Последний запуск",
|
||||||
"empty": "Профили не найдены."
|
"empty": "Профили не найдены.",
|
||||||
|
"notSelected": "Не выбрано"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"launch": "Запустить",
|
"launch": "Запустить",
|
||||||
@@ -488,7 +498,8 @@
|
|||||||
"deleteGroupAndProfiles": "Удалить группу и профили",
|
"deleteGroupAndProfiles": "Удалить группу и профили",
|
||||||
"loadProfilesFailed": "Не удалось загрузить профили",
|
"loadProfilesFailed": "Не удалось загрузить профили",
|
||||||
"unknownGroup": "Неизвестная группа",
|
"unknownGroup": "Неизвестная группа",
|
||||||
"profileGroupsAriaLabel": "Группы профилей"
|
"profileGroupsAriaLabel": "Группы профилей",
|
||||||
|
"loading": "Загрузка групп..."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"mode": {
|
"mode": {
|
||||||
@@ -631,7 +642,8 @@
|
|||||||
"mcpAcceptTermsFirst": "(Сначала примите условия Wayfern в Настройках)",
|
"mcpAcceptTermsFirst": "(Сначала примите условия Wayfern в Настройках)",
|
||||||
"mcpStarted": "MCP сервер запущен на порту {{port}}",
|
"mcpStarted": "MCP сервер запущен на порту {{port}}",
|
||||||
"mcpStopped": "MCP сервер остановлен",
|
"mcpStopped": "MCP сервер остановлен",
|
||||||
"mcpToggleFailed": "Не удалось переключить MCP сервер"
|
"mcpToggleFailed": "Не удалось переключить MCP сервер",
|
||||||
|
"openSettings": "Открыть настройки интеграций"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "Импорт профиля",
|
"title": "Импорт профиля",
|
||||||
@@ -711,6 +723,10 @@
|
|||||||
"webrtc": "Блокировать WebRTC",
|
"webrtc": "Блокировать WebRTC",
|
||||||
"webgl": "Блокировать WebGL"
|
"webgl": "Блокировать WebGL"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"shared": {
|
||||||
|
"browserBehavior": "Поведение браузера",
|
||||||
|
"allowAddonsOpenTabs": "Разрешить расширениям браузера автоматически открывать новые вкладки"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cookies": {
|
"cookies": {
|
||||||
@@ -875,7 +891,8 @@
|
|||||||
"loadProxiesFailed": "Не удалось загрузить прокси: {{error}}",
|
"loadProxiesFailed": "Не удалось загрузить прокси: {{error}}",
|
||||||
"setupProxyListenersFailed": "Не удалось настроить слушатели событий прокси: {{error}}",
|
"setupProxyListenersFailed": "Не удалось настроить слушатели событий прокси: {{error}}",
|
||||||
"loadVpnConfigsFailed": "Не удалось загрузить конфигурации VPN: {{error}}",
|
"loadVpnConfigsFailed": "Не удалось загрузить конфигурации VPN: {{error}}",
|
||||||
"setupVpnListenersFailed": "Не удалось настроить слушатели событий VPN: {{error}}"
|
"setupVpnListenersFailed": "Не удалось настроить слушатели событий VPN: {{error}}",
|
||||||
|
"themeNotFound": "Тема Tokyo Night не найдена"
|
||||||
},
|
},
|
||||||
"browser": {
|
"browser": {
|
||||||
"camoufox": "Camoufox",
|
"camoufox": "Camoufox",
|
||||||
@@ -906,10 +923,10 @@
|
|||||||
"platform": "Платформа",
|
"platform": "Платформа",
|
||||||
"platformVersion": "Версия платформы",
|
"platformVersion": "Версия платформы",
|
||||||
"appVersion": "Версия приложения",
|
"appVersion": "Версия приложения",
|
||||||
"osCpu": "OS CPU",
|
"osCpu": "ЦП ОС",
|
||||||
"hardwareConcurrency": "Количество потоков процессора",
|
"hardwareConcurrency": "Количество потоков процессора",
|
||||||
"maxTouchPoints": "Максимальное количество точек касания",
|
"maxTouchPoints": "Максимальное количество точек касания",
|
||||||
"doNotTrack": "Do Not Track",
|
"doNotTrack": "Не отслеживать",
|
||||||
"selectDntPlaceholder": "Выберите значение DNT",
|
"selectDntPlaceholder": "Выберите значение DNT",
|
||||||
"dntAllowed": "0 (отслеживание разрешено)",
|
"dntAllowed": "0 (отслеживание разрешено)",
|
||||||
"dntNotAllowed": "1 (отслеживание не разрешено)",
|
"dntNotAllowed": "1 (отслеживание не разрешено)",
|
||||||
@@ -931,8 +948,8 @@
|
|||||||
"outerHeight": "Внешняя высота",
|
"outerHeight": "Внешняя высота",
|
||||||
"innerWidth": "Внутренняя ширина",
|
"innerWidth": "Внутренняя ширина",
|
||||||
"innerHeight": "Внутренняя высота",
|
"innerHeight": "Внутренняя высота",
|
||||||
"screenX": "Screen X",
|
"screenX": "Экран X",
|
||||||
"screenY": "Screen Y",
|
"screenY": "Экран Y",
|
||||||
"geolocation": "Геолокация",
|
"geolocation": "Геолокация",
|
||||||
"timezoneAndGeolocation": "Часовой пояс и геолокация",
|
"timezoneAndGeolocation": "Часовой пояс и геолокация",
|
||||||
"timezoneGeolocationDescription": "Эти значения переопределяют API часового пояса и геолокации браузера.",
|
"timezoneGeolocationDescription": "Эти значения переопределяют API часового пояса и геолокации браузера.",
|
||||||
@@ -946,15 +963,15 @@
|
|||||||
"region": "Регион",
|
"region": "Регион",
|
||||||
"script": "Скрипт",
|
"script": "Скрипт",
|
||||||
"webglProperties": "Свойства WebGL",
|
"webglProperties": "Свойства WebGL",
|
||||||
"webglVendor": "WebGL Vendor",
|
"webglVendor": "Производитель WebGL",
|
||||||
"webglRenderer": "WebGL Renderer",
|
"webglRenderer": "Рендерер WebGL",
|
||||||
"webglParameters": "Параметры WebGL",
|
"webglParameters": "Параметры WebGL",
|
||||||
"webglParametersJson": "Параметры WebGL (JSON)",
|
"webglParametersJson": "Параметры WebGL (JSON)",
|
||||||
"webgl2Parameters": "Параметры WebGL2",
|
"webgl2Parameters": "Параметры WebGL2",
|
||||||
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
|
"webglShaderPrecisionFormats": "Форматы точности шейдера WebGL",
|
||||||
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
|
"webgl2ShaderPrecisionFormats": "Форматы точности шейдера WebGL2",
|
||||||
"canvasFingerprint": "Отпечаток Canvas",
|
"canvasFingerprint": "Отпечаток Canvas",
|
||||||
"canvasNoiseSeed": "Canvas Noise Seed",
|
"canvasNoiseSeed": "Сид шума Canvas",
|
||||||
"canvasNoiseSeedDescription": "Это зерно используется для генерации постоянного, но уникального отпечатка Canvas. У каждого профиля должно быть своё зерно.",
|
"canvasNoiseSeedDescription": "Это зерно используется для генерации постоянного, но уникального отпечатка Canvas. У каждого профиля должно быть своё зерно.",
|
||||||
"fonts": "Шрифты",
|
"fonts": "Шрифты",
|
||||||
"fontsJson": "Шрифты (JSON-массив)",
|
"fontsJson": "Шрифты (JSON-массив)",
|
||||||
@@ -975,8 +992,8 @@
|
|||||||
"maxChannelCount": "Максимальное количество каналов",
|
"maxChannelCount": "Максимальное количество каналов",
|
||||||
"vendorInfo": "Информация о производителе",
|
"vendorInfo": "Информация о производителе",
|
||||||
"vendor": "Производитель",
|
"vendor": "Производитель",
|
||||||
"vendorSub": "Vendor Sub",
|
"vendorSub": "Подверсия производителя",
|
||||||
"productSub": "Product Sub",
|
"productSub": "Подверсия продукта",
|
||||||
"brand": "Бренд",
|
"brand": "Бренд",
|
||||||
"brandVersion": "Версия бренда",
|
"brandVersion": "Версия бренда",
|
||||||
"proFeature": "Это функция Pro",
|
"proFeature": "Это функция Pro",
|
||||||
@@ -1124,7 +1141,9 @@
|
|||||||
"syncEnabled": "Синхронизация включена",
|
"syncEnabled": "Синхронизация включена",
|
||||||
"syncDisabled": "Синхронизация отключена",
|
"syncDisabled": "Синхронизация отключена",
|
||||||
"syncEnableTooltip": "Включить синхронизацию",
|
"syncEnableTooltip": "Включить синхронизацию",
|
||||||
"syncDisableTooltip": "Отключить синхронизацию"
|
"syncDisableTooltip": "Отключить синхронизацию",
|
||||||
|
"loadGroupsFailed": "Не удалось загрузить группы расширений",
|
||||||
|
"assignGroupFailed": "Не удалось назначить группу расширений"
|
||||||
},
|
},
|
||||||
"pro": {
|
"pro": {
|
||||||
"badge": "PRO",
|
"badge": "PRO",
|
||||||
@@ -1256,12 +1275,11 @@
|
|||||||
"importedSuccess": "Профиль «{{name}}» успешно импортирован",
|
"importedSuccess": "Профиль «{{name}}» успешно импортирован",
|
||||||
"notInstalled": "{{browser}} не установлен. Сначала загрузите {{browser}} из главного окна, затем попробуйте импортировать снова.",
|
"notInstalled": "{{browser}} не установлен. Сначала загрузите {{browser}} из главного окна, затем попробуйте импортировать снова.",
|
||||||
"importFailed": "Не удалось импортировать профиль: {{error}}",
|
"importFailed": "Не удалось импортировать профиль: {{error}}",
|
||||||
"importedAsPrefix": "Этот профиль будет импортирован как профиль",
|
|
||||||
"importedAsSuffix": ".",
|
|
||||||
"proxyOptional": "Прокси (необязательно)",
|
"proxyOptional": "Прокси (необязательно)",
|
||||||
"noProxy": "Без прокси",
|
"noProxy": "Без прокси",
|
||||||
"nextButton": "Далее",
|
"nextButton": "Далее",
|
||||||
"importButton": "Импорт"
|
"importButton": "Импорт",
|
||||||
|
"importedAs": "Этот профиль будет импортирован как профиль {{browser}}."
|
||||||
},
|
},
|
||||||
"syncTooltips": {
|
"syncTooltips": {
|
||||||
"syncing": "Синхронизация...",
|
"syncing": "Синхронизация...",
|
||||||
@@ -1424,7 +1442,11 @@
|
|||||||
"grantAccessButton": "Предоставить доступ",
|
"grantAccessButton": "Предоставить доступ",
|
||||||
"requestSuccessMicrophone": "Запрошен доступ к микрофону",
|
"requestSuccessMicrophone": "Запрошен доступ к микрофону",
|
||||||
"requestSuccessCamera": "Запрошен доступ к камере",
|
"requestSuccessCamera": "Запрошен доступ к камере",
|
||||||
"requestFailed": "Не удалось запросить разрешение"
|
"requestFailed": "Не удалось запросить разрешение",
|
||||||
|
"stillNotGrantedMicrophone": "Доступ к микрофону всё ещё не предоставлен. Возможно, потребуется включить его вручную в Системных настройках → Конфиденциальность и безопасность → Микрофон.",
|
||||||
|
"stillNotGrantedCamera": "Доступ к камере всё ещё не предоставлен. Возможно, потребуется включить его вручную в Системных настройках → Конфиденциальность и безопасность → Камера.",
|
||||||
|
"grantedToastMicrophone": "Доступ к микрофону предоставлен",
|
||||||
|
"grantedToastCamera": "Доступ к камере предоставлен"
|
||||||
},
|
},
|
||||||
"traffic": {
|
"traffic": {
|
||||||
"title": "Подробности трафика",
|
"title": "Подробности трафика",
|
||||||
@@ -1503,7 +1525,12 @@
|
|||||||
"syncTooltipNotSynced": "Не синхронизировано",
|
"syncTooltipNotSynced": "Не синхронизировано",
|
||||||
"noTags": "Нет тегов",
|
"noTags": "Нет тегов",
|
||||||
"syncTooltipCloseToSync": "Закройте профиль для синхронизации",
|
"syncTooltipCloseToSync": "Закройте профиль для синхронизации",
|
||||||
"syncTooltipDisabledWithLast": "Синхронизация отключена, последняя синхронизация {{time}}"
|
"syncTooltipDisabledWithLast": "Синхронизация отключена, последняя синхронизация {{time}}",
|
||||||
|
"addTagsPlaceholder": "Добавить теги",
|
||||||
|
"tagsHeader": "Теги",
|
||||||
|
"noteHeader": "Заметка",
|
||||||
|
"vpnsHeading": "VPN",
|
||||||
|
"createByCountryHeading": "Создать по стране"
|
||||||
},
|
},
|
||||||
"releaseTypeSelector": {
|
"releaseTypeSelector": {
|
||||||
"noReleaseTypes": "Нет доступных типов выпусков.",
|
"noReleaseTypes": "Нет доступных типов выпусков.",
|
||||||
@@ -1521,7 +1548,14 @@
|
|||||||
"appUpdate": {
|
"appUpdate": {
|
||||||
"toast": {
|
"toast": {
|
||||||
"updateFailed": "Не удалось обновить Donut Browser",
|
"updateFailed": "Не удалось обновить Donut Browser",
|
||||||
"restartFailed": "Не удалось перезапустить"
|
"restartFailed": "Не удалось перезапустить",
|
||||||
|
"updateReady": "Обновление готово, перезапустите для применения",
|
||||||
|
"manualDownloadRequired": "Требуется ручная загрузка",
|
||||||
|
"restartNow": "Перезапустить сейчас",
|
||||||
|
"viewRelease": "Посмотреть релиз",
|
||||||
|
"later": "Позже",
|
||||||
|
"uploading": "Загрузка",
|
||||||
|
"downloading": "Скачивание"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"browserDownload": {
|
"browserDownload": {
|
||||||
@@ -1532,7 +1566,10 @@
|
|||||||
"downloadFailed": "Не удалось загрузить {{browser}} {{version}}",
|
"downloadFailed": "Не удалось загрузить {{browser}} {{version}}",
|
||||||
"calculating": "вычисление...",
|
"calculating": "вычисление...",
|
||||||
"extractionFailed": "{{browser}} {{version}}: ошибка распаковки",
|
"extractionFailed": "{{browser}} {{version}}: ошибка распаковки",
|
||||||
"extractionFailedDescription": "Повреждённый файл удалён. Он будет повторно загружен при следующей попытке."
|
"extractionFailedDescription": "Повреждённый файл удалён. Он будет повторно загружен при следующей попытке.",
|
||||||
|
"extracting": "Распаковка файлов браузера... Не закрывайте приложение.",
|
||||||
|
"verifying": "Проверка файлов браузера...",
|
||||||
|
"downloadingRolling": "Загрузка rolling release сборки..."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"versionUpdater": {
|
"versionUpdater": {
|
||||||
|
|||||||
+65
-28
@@ -60,7 +60,8 @@
|
|||||||
"optional": "可选",
|
"optional": "可选",
|
||||||
"required": "必填",
|
"required": "必填",
|
||||||
"unknownProfile": "未知",
|
"unknownProfile": "未知",
|
||||||
"mode": "模式"
|
"mode": "模式",
|
||||||
|
"never": "从不"
|
||||||
},
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"days": "天",
|
"days": "天",
|
||||||
@@ -72,7 +73,11 @@
|
|||||||
"aria": {
|
"aria": {
|
||||||
"selectAll": "全选",
|
"selectAll": "全选",
|
||||||
"selectRow": "选择行",
|
"selectRow": "选择行",
|
||||||
"selectProfile": "选择配置文件"
|
"selectProfile": "选择配置文件",
|
||||||
|
"copy": "复制到剪贴板",
|
||||||
|
"copied": "已复制",
|
||||||
|
"showToken": "显示令牌",
|
||||||
|
"hideToken": "隐藏令牌"
|
||||||
},
|
},
|
||||||
"keys": {
|
"keys": {
|
||||||
"escape": "Esc"
|
"escape": "Esc"
|
||||||
@@ -87,7 +92,11 @@
|
|||||||
"title": "命令面板",
|
"title": "命令面板",
|
||||||
"description": "搜索要执行的命令..."
|
"description": "搜索要执行的命令..."
|
||||||
},
|
},
|
||||||
"noResults": "未找到结果。"
|
"noResults": "未找到结果。",
|
||||||
|
"srOnly": {
|
||||||
|
"copy": "复制",
|
||||||
|
"copied": "已复制"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "设置",
|
"title": "设置",
|
||||||
@@ -152,7 +161,7 @@
|
|||||||
"commercial": {
|
"commercial": {
|
||||||
"title": "商业许可",
|
"title": "商业许可",
|
||||||
"trialActive": "试用期:剩余 {{days}} 天 {{hours}} 小时",
|
"trialActive": "试用期:剩余 {{days}} 天 {{hours}} 小时",
|
||||||
"trialActiveDescription": "试用期内商业使用免费",
|
"trialActiveDescription": "试用期内商业使用免费。试用期结束后,所有功能继续正常使用 — 个人使用仍然免费,只有商业使用需要许可证。",
|
||||||
"trialExpired": "试用期已过期",
|
"trialExpired": "试用期已过期",
|
||||||
"trialExpiredDescription": "个人使用仍然免费。商业使用需要许可证。"
|
"trialExpiredDescription": "个人使用仍然免费。商业使用需要许可证。"
|
||||||
},
|
},
|
||||||
@@ -196,7 +205,8 @@
|
|||||||
"group": "分组",
|
"group": "分组",
|
||||||
"proxy": "代理 / VPN",
|
"proxy": "代理 / VPN",
|
||||||
"lastLaunch": "最后启动",
|
"lastLaunch": "最后启动",
|
||||||
"empty": "未找到配置文件。"
|
"empty": "未找到配置文件。",
|
||||||
|
"notSelected": "未选择"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"launch": "启动",
|
"launch": "启动",
|
||||||
@@ -488,7 +498,8 @@
|
|||||||
"deleteGroupAndProfiles": "删除组和配置文件",
|
"deleteGroupAndProfiles": "删除组和配置文件",
|
||||||
"loadProfilesFailed": "加载配置文件失败",
|
"loadProfilesFailed": "加载配置文件失败",
|
||||||
"unknownGroup": "未知分组",
|
"unknownGroup": "未知分组",
|
||||||
"profileGroupsAriaLabel": "配置文件分组"
|
"profileGroupsAriaLabel": "配置文件分组",
|
||||||
|
"loading": "正在加载组..."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"mode": {
|
"mode": {
|
||||||
@@ -631,7 +642,8 @@
|
|||||||
"mcpAcceptTermsFirst": "(请先在设置中接受 Wayfern 条款)",
|
"mcpAcceptTermsFirst": "(请先在设置中接受 Wayfern 条款)",
|
||||||
"mcpStarted": "MCP 服务器已在端口 {{port}} 上启动",
|
"mcpStarted": "MCP 服务器已在端口 {{port}} 上启动",
|
||||||
"mcpStopped": "MCP 服务器已停止",
|
"mcpStopped": "MCP 服务器已停止",
|
||||||
"mcpToggleFailed": "切换 MCP 服务器失败"
|
"mcpToggleFailed": "切换 MCP 服务器失败",
|
||||||
|
"openSettings": "打开集成设置"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"title": "导入配置文件",
|
"title": "导入配置文件",
|
||||||
@@ -711,6 +723,10 @@
|
|||||||
"webrtc": "阻止 WebRTC",
|
"webrtc": "阻止 WebRTC",
|
||||||
"webgl": "阻止 WebGL"
|
"webgl": "阻止 WebGL"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"shared": {
|
||||||
|
"browserBehavior": "浏览器行为",
|
||||||
|
"allowAddonsOpenTabs": "允许浏览器附加组件自动打开新标签页"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cookies": {
|
"cookies": {
|
||||||
@@ -875,7 +891,8 @@
|
|||||||
"loadProxiesFailed": "加载代理失败: {{error}}",
|
"loadProxiesFailed": "加载代理失败: {{error}}",
|
||||||
"setupProxyListenersFailed": "设置代理事件监听器失败: {{error}}",
|
"setupProxyListenersFailed": "设置代理事件监听器失败: {{error}}",
|
||||||
"loadVpnConfigsFailed": "加载 VPN 配置失败: {{error}}",
|
"loadVpnConfigsFailed": "加载 VPN 配置失败: {{error}}",
|
||||||
"setupVpnListenersFailed": "设置 VPN 事件监听器失败: {{error}}"
|
"setupVpnListenersFailed": "设置 VPN 事件监听器失败: {{error}}",
|
||||||
|
"themeNotFound": "未找到 Tokyo Night 主题"
|
||||||
},
|
},
|
||||||
"browser": {
|
"browser": {
|
||||||
"camoufox": "Camoufox",
|
"camoufox": "Camoufox",
|
||||||
@@ -901,15 +918,15 @@
|
|||||||
"blockWebRTC": "阻止 WebRTC",
|
"blockWebRTC": "阻止 WebRTC",
|
||||||
"blockWebGL": "阻止 WebGL",
|
"blockWebGL": "阻止 WebGL",
|
||||||
"navigatorProperties": "Navigator 属性",
|
"navigatorProperties": "Navigator 属性",
|
||||||
"userAgent": "User Agent",
|
"userAgent": "用户代理",
|
||||||
"userAgentAndPlatform": "User Agent 和平台",
|
"userAgentAndPlatform": "User Agent 和平台",
|
||||||
"platform": "平台",
|
"platform": "平台",
|
||||||
"platformVersion": "平台版本",
|
"platformVersion": "平台版本",
|
||||||
"appVersion": "应用版本",
|
"appVersion": "应用版本",
|
||||||
"osCpu": "OS CPU",
|
"osCpu": "操作系统 CPU",
|
||||||
"hardwareConcurrency": "硬件并发数",
|
"hardwareConcurrency": "硬件并发数",
|
||||||
"maxTouchPoints": "最大触摸点数",
|
"maxTouchPoints": "最大触摸点数",
|
||||||
"doNotTrack": "Do Not Track",
|
"doNotTrack": "请勿跟踪",
|
||||||
"selectDntPlaceholder": "选择 DNT 值",
|
"selectDntPlaceholder": "选择 DNT 值",
|
||||||
"dntAllowed": "0(允许跟踪)",
|
"dntAllowed": "0(允许跟踪)",
|
||||||
"dntNotAllowed": "1(不允许跟踪)",
|
"dntNotAllowed": "1(不允许跟踪)",
|
||||||
@@ -931,8 +948,8 @@
|
|||||||
"outerHeight": "外部高度",
|
"outerHeight": "外部高度",
|
||||||
"innerWidth": "内部宽度",
|
"innerWidth": "内部宽度",
|
||||||
"innerHeight": "内部高度",
|
"innerHeight": "内部高度",
|
||||||
"screenX": "Screen X",
|
"screenX": "屏幕 X",
|
||||||
"screenY": "Screen Y",
|
"screenY": "屏幕 Y",
|
||||||
"geolocation": "地理位置",
|
"geolocation": "地理位置",
|
||||||
"timezoneAndGeolocation": "时区和地理位置",
|
"timezoneAndGeolocation": "时区和地理位置",
|
||||||
"timezoneGeolocationDescription": "这些值会覆盖浏览器的时区和地理位置 API。",
|
"timezoneGeolocationDescription": "这些值会覆盖浏览器的时区和地理位置 API。",
|
||||||
@@ -946,15 +963,15 @@
|
|||||||
"region": "地区",
|
"region": "地区",
|
||||||
"script": "脚本",
|
"script": "脚本",
|
||||||
"webglProperties": "WebGL 属性",
|
"webglProperties": "WebGL 属性",
|
||||||
"webglVendor": "WebGL Vendor",
|
"webglVendor": "WebGL 供应商",
|
||||||
"webglRenderer": "WebGL Renderer",
|
"webglRenderer": "WebGL 渲染器",
|
||||||
"webglParameters": "WebGL 参数",
|
"webglParameters": "WebGL 参数",
|
||||||
"webglParametersJson": "WebGL 参数 (JSON)",
|
"webglParametersJson": "WebGL 参数 (JSON)",
|
||||||
"webgl2Parameters": "WebGL2 参数",
|
"webgl2Parameters": "WebGL2 参数",
|
||||||
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
|
"webglShaderPrecisionFormats": "WebGL 着色器精度格式",
|
||||||
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
|
"webgl2ShaderPrecisionFormats": "WebGL2 着色器精度格式",
|
||||||
"canvasFingerprint": "Canvas 指纹",
|
"canvasFingerprint": "Canvas 指纹",
|
||||||
"canvasNoiseSeed": "Canvas Noise Seed",
|
"canvasNoiseSeed": "Canvas 噪声种子",
|
||||||
"canvasNoiseSeedDescription": "此种子用于生成一致但唯一的 Canvas 指纹。每个配置文件应使用不同的种子。",
|
"canvasNoiseSeedDescription": "此种子用于生成一致但唯一的 Canvas 指纹。每个配置文件应使用不同的种子。",
|
||||||
"fonts": "字体",
|
"fonts": "字体",
|
||||||
"fontsJson": "字体 (JSON 数组)",
|
"fontsJson": "字体 (JSON 数组)",
|
||||||
@@ -975,8 +992,8 @@
|
|||||||
"maxChannelCount": "最大通道数",
|
"maxChannelCount": "最大通道数",
|
||||||
"vendorInfo": "供应商信息",
|
"vendorInfo": "供应商信息",
|
||||||
"vendor": "供应商",
|
"vendor": "供应商",
|
||||||
"vendorSub": "Vendor Sub",
|
"vendorSub": "供应商子版本",
|
||||||
"productSub": "Product Sub",
|
"productSub": "产品子版本",
|
||||||
"brand": "品牌",
|
"brand": "品牌",
|
||||||
"brandVersion": "品牌版本",
|
"brandVersion": "品牌版本",
|
||||||
"proFeature": "这是 Pro 功能",
|
"proFeature": "这是 Pro 功能",
|
||||||
@@ -1124,7 +1141,9 @@
|
|||||||
"syncEnabled": "同步已启用",
|
"syncEnabled": "同步已启用",
|
||||||
"syncDisabled": "同步已禁用",
|
"syncDisabled": "同步已禁用",
|
||||||
"syncEnableTooltip": "启用同步",
|
"syncEnableTooltip": "启用同步",
|
||||||
"syncDisableTooltip": "禁用同步"
|
"syncDisableTooltip": "禁用同步",
|
||||||
|
"loadGroupsFailed": "加载扩展组失败",
|
||||||
|
"assignGroupFailed": "分配扩展组失败"
|
||||||
},
|
},
|
||||||
"pro": {
|
"pro": {
|
||||||
"badge": "PRO",
|
"badge": "PRO",
|
||||||
@@ -1256,12 +1275,11 @@
|
|||||||
"importedSuccess": "已成功导入配置文件「{{name}}」",
|
"importedSuccess": "已成功导入配置文件「{{name}}」",
|
||||||
"notInstalled": "{{browser}} 未安装。请先从主窗口下载 {{browser}},然后再尝试导入。",
|
"notInstalled": "{{browser}} 未安装。请先从主窗口下载 {{browser}},然后再尝试导入。",
|
||||||
"importFailed": "导入配置文件失败: {{error}}",
|
"importFailed": "导入配置文件失败: {{error}}",
|
||||||
"importedAsPrefix": "此配置文件将作为以下配置文件导入:",
|
|
||||||
"importedAsSuffix": "",
|
|
||||||
"proxyOptional": "代理 (可选)",
|
"proxyOptional": "代理 (可选)",
|
||||||
"noProxy": "无代理",
|
"noProxy": "无代理",
|
||||||
"nextButton": "下一步",
|
"nextButton": "下一步",
|
||||||
"importButton": "导入"
|
"importButton": "导入",
|
||||||
|
"importedAs": "此配置文件将作为 {{browser}} 配置文件导入。"
|
||||||
},
|
},
|
||||||
"syncTooltips": {
|
"syncTooltips": {
|
||||||
"syncing": "同步中...",
|
"syncing": "同步中...",
|
||||||
@@ -1424,7 +1442,11 @@
|
|||||||
"grantAccessButton": "授予访问",
|
"grantAccessButton": "授予访问",
|
||||||
"requestSuccessMicrophone": "已请求麦克风访问",
|
"requestSuccessMicrophone": "已请求麦克风访问",
|
||||||
"requestSuccessCamera": "已请求摄像头访问",
|
"requestSuccessCamera": "已请求摄像头访问",
|
||||||
"requestFailed": "请求权限失败"
|
"requestFailed": "请求权限失败",
|
||||||
|
"stillNotGrantedMicrophone": "麦克风访问权限仍未授予。您可能需要在系统设置 → 隐私与安全 → 麦克风中手动启用。",
|
||||||
|
"stillNotGrantedCamera": "摄像头访问权限仍未授予。您可能需要在系统设置 → 隐私与安全 → 摄像头中手动启用。",
|
||||||
|
"grantedToastMicrophone": "已授予麦克风访问权限",
|
||||||
|
"grantedToastCamera": "已授予摄像头访问权限"
|
||||||
},
|
},
|
||||||
"traffic": {
|
"traffic": {
|
||||||
"title": "流量详情",
|
"title": "流量详情",
|
||||||
@@ -1503,7 +1525,12 @@
|
|||||||
"syncTooltipNotSynced": "未同步",
|
"syncTooltipNotSynced": "未同步",
|
||||||
"noTags": "无标签",
|
"noTags": "无标签",
|
||||||
"syncTooltipCloseToSync": "关闭配置文件以进行同步",
|
"syncTooltipCloseToSync": "关闭配置文件以进行同步",
|
||||||
"syncTooltipDisabledWithLast": "同步已禁用,上次同步 {{time}}"
|
"syncTooltipDisabledWithLast": "同步已禁用,上次同步 {{time}}",
|
||||||
|
"addTagsPlaceholder": "添加标签",
|
||||||
|
"tagsHeader": "标签",
|
||||||
|
"noteHeader": "备注",
|
||||||
|
"vpnsHeading": "VPN",
|
||||||
|
"createByCountryHeading": "按国家创建"
|
||||||
},
|
},
|
||||||
"releaseTypeSelector": {
|
"releaseTypeSelector": {
|
||||||
"noReleaseTypes": "没有可用的发布类型。",
|
"noReleaseTypes": "没有可用的发布类型。",
|
||||||
@@ -1521,7 +1548,14 @@
|
|||||||
"appUpdate": {
|
"appUpdate": {
|
||||||
"toast": {
|
"toast": {
|
||||||
"updateFailed": "更新 Donut Browser 失败",
|
"updateFailed": "更新 Donut Browser 失败",
|
||||||
"restartFailed": "重启失败"
|
"restartFailed": "重启失败",
|
||||||
|
"updateReady": "更新就绪,请重启以应用",
|
||||||
|
"manualDownloadRequired": "需要手动下载",
|
||||||
|
"restartNow": "立即重启",
|
||||||
|
"viewRelease": "查看版本",
|
||||||
|
"later": "稍后",
|
||||||
|
"uploading": "上传中",
|
||||||
|
"downloading": "下载中"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"browserDownload": {
|
"browserDownload": {
|
||||||
@@ -1532,7 +1566,10 @@
|
|||||||
"downloadFailed": "下载 {{browser}} {{version}} 失败",
|
"downloadFailed": "下载 {{browser}} {{version}} 失败",
|
||||||
"calculating": "计算中...",
|
"calculating": "计算中...",
|
||||||
"extractionFailed": "{{browser}} {{version}}: 解压失败",
|
"extractionFailed": "{{browser}} {{version}}: 解压失败",
|
||||||
"extractionFailedDescription": "损坏的文件已删除。下次尝试时将重新下载。"
|
"extractionFailedDescription": "损坏的文件已删除。下次尝试时将重新下载。",
|
||||||
|
"extracting": "正在提取浏览器文件...请不要关闭应用。",
|
||||||
|
"verifying": "正在验证浏览器文件...",
|
||||||
|
"downloadingRolling": "正在下载滚动发布版本..."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"versionUpdater": {
|
"versionUpdater": {
|
||||||
|
|||||||
+414
-99
@@ -197,38 +197,45 @@ export const THEMES: Theme[] = [
|
|||||||
{
|
{
|
||||||
id: "ayu-light",
|
id: "ayu-light",
|
||||||
name: "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: {
|
colors: {
|
||||||
"--background": "#fafafa",
|
"--background": "#f8f9fa",
|
||||||
"--foreground": "#5c6773",
|
"--foreground": "#5c6166",
|
||||||
"--card": "#ffffff",
|
"--card": "#fcfcfc",
|
||||||
"--card-foreground": "#5c6773",
|
"--card-foreground": "#5c6166",
|
||||||
"--popover": "#ffffff",
|
"--popover": "#ffffff",
|
||||||
"--popover-foreground": "#5c6773",
|
"--popover-foreground": "#5c6166",
|
||||||
"--primary": "#399ee6",
|
"--primary": "#f29718",
|
||||||
"--primary-foreground": "#fafafa",
|
"--primary-foreground": "#ffffff",
|
||||||
"--secondary": "#fa8d3e",
|
"--secondary": "#399ee6",
|
||||||
"--secondary-foreground": "#fafafa",
|
"--secondary-foreground": "#ffffff",
|
||||||
"--muted": "#f0f0f0",
|
"--muted": "#ebeef0",
|
||||||
"--muted-foreground": "#828c99",
|
"--muted-foreground": "#828e9f",
|
||||||
"--accent": "#a37acc",
|
"--accent": "#a37acc",
|
||||||
"--accent-foreground": "#fafafa",
|
"--accent-foreground": "#ffffff",
|
||||||
"--destructive": "#f07178",
|
"--destructive": "#e65050",
|
||||||
"--destructive-foreground": "#fafafa",
|
"--destructive-foreground": "#ffffff",
|
||||||
"--success": "#86b300",
|
"--success": "#86b300",
|
||||||
"--success-foreground": "#fafafa",
|
"--success-foreground": "#ffffff",
|
||||||
"--warning": "#fa8d3e",
|
"--warning": "#fa8532",
|
||||||
"--warning-foreground": "#fafafa",
|
"--warning-foreground": "#ffffff",
|
||||||
"--border": "#e7eaed",
|
"--border": "#c8d0d6",
|
||||||
"--chart-1": "#399ee6",
|
"--chart-1": "#f29718",
|
||||||
"--chart-2": "#86b300",
|
"--chart-2": "#86b300",
|
||||||
"--chart-3": "#a37acc",
|
"--chart-3": "#a37acc",
|
||||||
"--chart-4": "#fa8d3e",
|
"--chart-4": "#399ee6",
|
||||||
"--chart-5": "#f07178",
|
"--chart-5": "#4cbf99",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "catppuccin-latte",
|
id: "catppuccin-latte",
|
||||||
name: "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: {
|
colors: {
|
||||||
"--background": "#eff1f5",
|
"--background": "#eff1f5",
|
||||||
"--foreground": "#4c4f69",
|
"--foreground": "#4c4f69",
|
||||||
@@ -236,13 +243,13 @@ export const THEMES: Theme[] = [
|
|||||||
"--card-foreground": "#4c4f69",
|
"--card-foreground": "#4c4f69",
|
||||||
"--popover": "#ccd0da",
|
"--popover": "#ccd0da",
|
||||||
"--popover-foreground": "#4c4f69",
|
"--popover-foreground": "#4c4f69",
|
||||||
"--primary": "#1e66f5",
|
"--primary": "#8839ef",
|
||||||
"--primary-foreground": "#eff1f5",
|
"--primary-foreground": "#eff1f5",
|
||||||
"--secondary": "#04a5e5",
|
"--secondary": "#1e66f5",
|
||||||
"--secondary-foreground": "#eff1f5",
|
"--secondary-foreground": "#eff1f5",
|
||||||
"--muted": "#bcc0cc",
|
"--muted": "#bcc0cc",
|
||||||
"--muted-foreground": "#5c5f77",
|
"--muted-foreground": "#6c6f85",
|
||||||
"--accent": "#8839ef",
|
"--accent": "#ea76cb",
|
||||||
"--accent-foreground": "#eff1f5",
|
"--accent-foreground": "#eff1f5",
|
||||||
"--destructive": "#d20f39",
|
"--destructive": "#d20f39",
|
||||||
"--destructive-foreground": "#eff1f5",
|
"--destructive-foreground": "#eff1f5",
|
||||||
@@ -251,80 +258,18 @@ export const THEMES: Theme[] = [
|
|||||||
"--warning": "#df8e1d",
|
"--warning": "#df8e1d",
|
||||||
"--warning-foreground": "#eff1f5",
|
"--warning-foreground": "#eff1f5",
|
||||||
"--border": "#9ca0b0",
|
"--border": "#9ca0b0",
|
||||||
"--chart-1": "#1e66f5",
|
"--chart-1": "#8839ef",
|
||||||
"--chart-2": "#40a02b",
|
"--chart-2": "#40a02b",
|
||||||
"--chart-3": "#8839ef",
|
"--chart-3": "#ea76cb",
|
||||||
"--chart-4": "#04a5e5",
|
"--chart-4": "#04a5e5",
|
||||||
"--chart-5": "#df8e1d",
|
"--chart-5": "#fe640b",
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "catppuccin-mocha",
|
id: "catppuccin-mocha",
|
||||||
name: "Catppuccin Mocha",
|
name: "Catppuccin Mocha",
|
||||||
|
// Source: github.com/catppuccin/palette/blob/main/palette.json
|
||||||
|
// Primary uses mauve (purple) — Catppuccin's signature colour.
|
||||||
colors: {
|
colors: {
|
||||||
"--background": "#1e1e2e",
|
"--background": "#1e1e2e",
|
||||||
"--foreground": "#cdd6f4",
|
"--foreground": "#cdd6f4",
|
||||||
@@ -332,13 +277,13 @@ export const THEMES: Theme[] = [
|
|||||||
"--card-foreground": "#cdd6f4",
|
"--card-foreground": "#cdd6f4",
|
||||||
"--popover": "#313244",
|
"--popover": "#313244",
|
||||||
"--popover-foreground": "#cdd6f4",
|
"--popover-foreground": "#cdd6f4",
|
||||||
"--primary": "#89b4fa",
|
"--primary": "#cba6f7",
|
||||||
"--primary-foreground": "#1e1e2e",
|
"--primary-foreground": "#1e1e2e",
|
||||||
"--secondary": "#89dceb",
|
"--secondary": "#89b4fa",
|
||||||
"--secondary-foreground": "#1e1e2e",
|
"--secondary-foreground": "#1e1e2e",
|
||||||
"--muted": "#45475a",
|
"--muted": "#45475a",
|
||||||
"--muted-foreground": "#bac2de",
|
"--muted-foreground": "#a6adc8",
|
||||||
"--accent": "#cba6f7",
|
"--accent": "#f5c2e7",
|
||||||
"--accent-foreground": "#1e1e2e",
|
"--accent-foreground": "#1e1e2e",
|
||||||
"--destructive": "#f38ba8",
|
"--destructive": "#f38ba8",
|
||||||
"--destructive-foreground": "#1e1e2e",
|
"--destructive-foreground": "#1e1e2e",
|
||||||
@@ -347,11 +292,381 @@ export const THEMES: Theme[] = [
|
|||||||
"--warning": "#f9e2af",
|
"--warning": "#f9e2af",
|
||||||
"--warning-foreground": "#1e1e2e",
|
"--warning-foreground": "#1e1e2e",
|
||||||
"--border": "#585b70",
|
"--border": "#585b70",
|
||||||
"--chart-1": "#89b4fa",
|
"--chart-1": "#cba6f7",
|
||||||
"--chart-2": "#a6e3a1",
|
"--chart-2": "#a6e3a1",
|
||||||
"--chart-3": "#cba6f7",
|
"--chart-3": "#f5c2e7",
|
||||||
"--chart-4": "#89dceb",
|
"--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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user