mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-11 17:27:54 +02:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35a874ead0 | |||
| f02397dba9 | |||
| d5752633c8 | |||
| 5752260018 | |||
| 405d7c5716 | |||
| 7d9bed2114 | |||
| 2633e2ba09 | |||
| 06b5a41b37 | |||
| bb5f4ea166 | |||
| 9c1cb011a5 | |||
| ed3c209f35 | |||
| 739b5e2449 | |||
| c3e498fc6e | |||
| b5f000849f | |||
| 722aaecbbe | |||
| 85e0072915 | |||
| 50d918eeda | |||
| 2e0ee1ddfe | |||
| 8dc48ef526 | |||
| bc3c2c8cca | |||
| b4a8fd04d8 | |||
| 5bff4438f0 | |||
| 0fe3e5bc50 | |||
| 90ccf77e3f | |||
| 88e6d7e116 | |||
| dd613a4d59 | |||
| cabb5a3e23 | |||
| c981e18a7b | |||
| 982ed36401 | |||
| 4b52ced71f | |||
| 99f9e04553 | |||
| 53165e3cf0 | |||
| 29e73bd2d8 | |||
| 6441843d85 | |||
| 5356d59d72 | |||
| 34450ad06b | |||
| 904dda2bad | |||
| 39b13ead5b | |||
| 62c84b52fc | |||
| 828c3bb984 | |||
| ffe35c1672 | |||
| 4a4cf81255 | |||
| 77be8cadaf | |||
| 3207e4fbd3 | |||
| c18e9625fd | |||
| d06ddccd78 | |||
| 04297fc27d | |||
| 1d404833ad | |||
| f61a3905fa | |||
| 79d8b83b57 | |||
| e700b47b4c | |||
| 57167b979f |
@@ -1,4 +1,12 @@
|
||||
|
||||
# macOS code signing + notarization for `pnpm tauri build`.
|
||||
# Loaded into the build environment via scripts/run-with-env.mjs (and direnv via .envrc).
|
||||
# APPLE_SIGNING_IDENTITY: the exact name of your Developer ID Application
|
||||
# certificate as it appears in `security find-identity -v -p codesigning`.
|
||||
# Example: "Developer ID Application: Your Name (TEAMID)"
|
||||
# APPLE_ID + APPLE_PASSWORD + APPLE_TEAM_ID: credentials for notarytool.
|
||||
# APPLE_PASSWORD must be an app-specific password from appleid.apple.com,
|
||||
# not your real Apple ID password.
|
||||
APPLE_TEAM_ID=
|
||||
APPLE_ID=
|
||||
APPLE_PASSWORD=
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
use flake
|
||||
# Load .env on top of the flake's environment so APPLE_SIGNING_IDENTITY,
|
||||
# APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID etc. are available to `tauri build`
|
||||
# and any other tools spawned from this directory.
|
||||
dotenv_if_exists .env
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ permissions:
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
# Single source of truth for the model used by both triage and composer.
|
||||
TRIAGE_MODEL: anthropic/claude-opus-4.7
|
||||
COMPOSER_MODEL: anthropic/claude-opus-4.7
|
||||
|
||||
jobs:
|
||||
analyze-issue:
|
||||
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'issues'
|
||||
@@ -40,42 +45,207 @@ jobs:
|
||||
echo "is_first_time=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Build repo context and find related files
|
||||
- name: Parse issue template fields
|
||||
env:
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
run: |
|
||||
node <<'EOF'
|
||||
const fs = require('node:fs');
|
||||
const body = process.env.ISSUE_BODY || '';
|
||||
// GitHub issue templates render fields as `### Heading\nValue` blocks.
|
||||
// Split on `###` at line start to recover them.
|
||||
const fields = {};
|
||||
const sections = body.split(/^###\s+/m);
|
||||
for (const section of sections.slice(1)) {
|
||||
const nl = section.indexOf('\n');
|
||||
if (nl < 0) continue;
|
||||
const heading = section.slice(0, nl).trim();
|
||||
const value = section.slice(nl + 1).trim();
|
||||
fields[heading] = value === '_No response_' ? '' : value;
|
||||
}
|
||||
fs.writeFileSync('/tmp/issue-fields.json', JSON.stringify(fields, null, 2));
|
||||
// Convenience extractions for the prompt — empty string if missing.
|
||||
const get = (k) => fields[k] || '';
|
||||
fs.writeFileSync('/tmp/issue-os.txt', get('Operating System'));
|
||||
fs.writeFileSync('/tmp/issue-version.txt', get('Donut Browser version'));
|
||||
fs.writeFileSync('/tmp/issue-browser.txt', get('Which browser is affected?'));
|
||||
fs.writeFileSync('/tmp/issue-repro.txt', get('Steps to reproduce'));
|
||||
fs.writeFileSync('/tmp/issue-logs.txt', get('Error logs or screenshots'));
|
||||
fs.writeFileSync('/tmp/issue-what.txt', get('What happened?') || get('What do you want?'));
|
||||
EOF
|
||||
echo "Parsed fields:"
|
||||
cat /tmp/issue-fields.json
|
||||
|
||||
- name: Build repo context
|
||||
env:
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
run: |
|
||||
# Read project guidelines (contains repo structure)
|
||||
cp CLAUDE.md /tmp/repo-context.txt
|
||||
|
||||
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
|
||||
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
|
||||
|
||||
# List all source files for the AI to pick from
|
||||
# List all source files for the AI to choose from
|
||||
find . -type f \( -name "*.rs" -o -name "*.ts" -o -name "*.tsx" \) \
|
||||
! -path "*/node_modules/*" ! -path "*/target/*" ! -path "*/.next/*" ! -path "*/dist/*" \
|
||||
! -path "*/.git/*" ! -path "*/gen/*" ! -path "*/data/*" \
|
||||
| sed 's|^\./||' | sort > /tmp/all-source-files.txt
|
||||
|
||||
- name: Select relevant files with AI
|
||||
- name: Write shared knowledge files (scope + pricing)
|
||||
run: |
|
||||
cat > /tmp/scope-and-pricing.md <<'EOF'
|
||||
# PROJECT SCOPE
|
||||
|
||||
- **Donut Browser** — this repo. A Tauri desktop launcher (Rust + Next.js) that
|
||||
downloads, manages, and launches anti-detect browser profiles. In-scope for bug
|
||||
reports about profile management, downloads, sync, proxy, VPN, the launcher UI,
|
||||
its API, MCP server, and the bundled `donut-sync` self-hosted server.
|
||||
- **Wayfern** — a Chromium fork maintained by zhom (the same maintainer). Wayfern
|
||||
bugs are in-scope here unless they are obviously upstream Chromium issues.
|
||||
- **Camoufox** — a Firefox fork by daijro. The maintainer of THIS repo does NOT
|
||||
contribute to Camoufox and CANNOT fix bugs in it.
|
||||
- Bugs about Camoufox's *internal* behavior (page rendering, JS engine,
|
||||
dropdowns, form widgets, fingerprinting *as Camoufox implements it*,
|
||||
checkbox/radio quirks) are UPSTREAM ONLY. Redirect to
|
||||
https://github.com/daijro/camoufox/issues.
|
||||
- Bugs about how Donut *launches, configures, or downloads* Camoufox are
|
||||
in-scope here.
|
||||
- **Forks of Wayfern or Camoufox** (e.g. CloverLabsAI, VulpineOS) are NOT
|
||||
supported. Feature requests asking for them are out of scope.
|
||||
|
||||
# PAID vs FREE FEATURES
|
||||
|
||||
Source: donutbrowser.com pricing tiers (verbatim from translations).
|
||||
|
||||
## Free (no account required)
|
||||
- Unlimited local profiles
|
||||
- Chromium (Wayfern) and Firefox (Camoufox) browser engines
|
||||
- Proxy support (HTTP/SOCKS5)
|
||||
- VPN support (WireGuard)
|
||||
- Profile Management API & MCP (list / create / launch / kill / config)
|
||||
- Cookie & Extension Management
|
||||
- Set as default browser
|
||||
- **Profile sync IS FREE if the user self-hosts the `donut-sync` server**
|
||||
|
||||
## Pro ($16/mo) — adds:
|
||||
- Browser Manipulation API & MCP (`type_text`, `click_element`,
|
||||
`evaluate_javascript`, `screenshot`, `navigate`, etc.)
|
||||
- Cross-OS fingerprinting (e.g. macOS user appearing as Windows)
|
||||
- Profile Synchronizer for Wayfern
|
||||
- 20 cloud profile backup (cloud sync via donutbrowser.com)
|
||||
- Commercial use license
|
||||
|
||||
## Team ($80/mo) — adds:
|
||||
- 100 cloud profile sync
|
||||
- Team collaboration, profile sharing, unlimited seats
|
||||
|
||||
# ANTI-PATTERNS
|
||||
|
||||
- **Regression**: user explicitly mentions a previous version that worked
|
||||
differently ("worked in 0.21", "went from 2 to 8 false positives"). Do NOT
|
||||
dismiss as "known issue" / "expected" / "false positive in Tauri apps". Ask
|
||||
which exact version was the last working one and what changed.
|
||||
- **Out-of-scope (upstream Camoufox)**: report is about Camoufox's own
|
||||
behavior. Redirect, do not collect logs.
|
||||
- **Fork-support request**: asks the maintainer to support an alternative
|
||||
Wayfern/Camoufox fork. Acknowledge in one neutral sentence — do NOT call it
|
||||
"clear", "reasonable", "well-thought-out", etc.
|
||||
- **AI-generated / template-violating report**: report doesn't follow the
|
||||
template, may cite "official documentation" via context7, deepwiki, or any
|
||||
non-`donutbrowser.com` / non-`github.com/zhom` URL. The only authoritative
|
||||
sources are this GitHub repo and donutbrowser.com.
|
||||
- **Speculation about internals**: never write a "Possible cause" / "Likely
|
||||
cause" / "Root cause" section. Never cite internal file paths or line
|
||||
numbers. Never speculate about how subscription / paid-plan checks work.
|
||||
|
||||
# OS-SPECIFIC LOG PATHS (use ONLY the one matching the user's OS)
|
||||
|
||||
- macOS: `~/Library/Logs/com.donutbrowser/`
|
||||
- Linux: `~/.local/share/com.donutbrowser/logs/`
|
||||
- Windows: `%APPDATA%\com.donutbrowser\logs\`
|
||||
|
||||
# KNOWN ERROR SIGNATURES (truth, not guesses — match these
|
||||
# verbatim before suggesting anything else)
|
||||
|
||||
- **`CDP not ready after N attempts on port X: HTTP 5xx ...`** —
|
||||
an HTTP 5xx (503 / 502) response from a freshly-launched
|
||||
browser's `/json/version` endpoint always means *something on
|
||||
the loopback path is intercepting the connection*: a firewall,
|
||||
an antivirus web-shield (Kaspersky, Bitdefender, ESET, Avast /
|
||||
AVG, Yandex Protect on Windows; Little Snitch, LuLu on macOS),
|
||||
a VPN client that hijacks 127.0.0.1, or a corporate MDM /
|
||||
proxy (Zscaler, Cisco AnyConnect, Netskope). Chrome's
|
||||
DevTools endpoint never returns 5xx itself — only synthetic
|
||||
responses from interception layers do. **Do NOT speculate
|
||||
about Gatekeeper, first-launch verification, code signing, or
|
||||
quarantine** — none of those cause a 5xx response, and
|
||||
Gatekeeper never delays a launch long enough to surface as
|
||||
"120 attempts". Lead with: which AV / web-shield / firewall /
|
||||
VPN / MDM is installed, and ask the user to try with the AV's
|
||||
web-shield component temporarily disabled (not the whole AV).
|
||||
EOF
|
||||
|
||||
- name: Build triage system prompt
|
||||
run: |
|
||||
# The static system prompt has apostrophes ("doesn't", "official docs"
|
||||
# etc.) that collide with shell single-quoting if embedded directly in
|
||||
# the jq filter. Build the full prompt to a file instead, then load it
|
||||
# via --rawfile in the next step.
|
||||
{
|
||||
cat <<'TRIAGE_HEAD'
|
||||
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.
|
||||
|
||||
TRIAGE_HEAD
|
||||
cat /tmp/scope-and-pricing.md
|
||||
printf '\n\n# REPO GUIDELINES\n'
|
||||
cat /tmp/repo-context.txt
|
||||
cat <<'TRIAGE_TAIL'
|
||||
|
||||
# OUTPUT
|
||||
Return ONLY valid JSON. No preamble, no code fences. Schema:
|
||||
{
|
||||
"language": "en" or ISO 639-1 code,
|
||||
"classification": one of ["bug-in-scope", "bug-upstream-camoufox", "bug-template-violation", "feature-request", "fork-request", "regression", "ai-generated-junk", "question", "other"],
|
||||
"operating_system": "macos" | "windows" | "linux" | "unknown",
|
||||
"is_paid_feature": true | false,
|
||||
"user_followed_template": true | false,
|
||||
"regression_signal": quoted user snippet or null,
|
||||
"user_cited_external_docs": URL string or null,
|
||||
"files_to_read": array of at most 20 file paths from the list,
|
||||
"notes": one short sentence describing what you observed
|
||||
}
|
||||
|
||||
Classification guidance:
|
||||
- "bug-upstream-camoufox": Camoufox-internal behavior (rendering, dropdowns, JS, fingerprint impl). NOT how Donut launches it.
|
||||
- "bug-template-violation": missing or filled-in nonsense for required template fields.
|
||||
- "ai-generated-junk": cites fabricated "official docs" (context7, deepwiki, non-donutbrowser URLs) or has the polished AI-spam shape (long, structured, fabricated certainty).
|
||||
- "fork-request": asks for support of CloverLabsAI/VulpineOS/etc. forks.
|
||||
- "regression": user names a prior version that worked.
|
||||
|
||||
File 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.
|
||||
TRIAGE_TAIL
|
||||
} > /tmp/triage-system.txt
|
||||
wc -c /tmp/triage-system.txt
|
||||
|
||||
- name: Stage 1 — Triage and file selection
|
||||
env:
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
run: |
|
||||
# The triage call returns ONLY JSON. It classifies the issue and picks a
|
||||
# short list of source files for the composer to read.
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg model "$TRIAGE_MODEL" \
|
||||
--rawfile system_prompt /tmp/triage-system.txt \
|
||||
--rawfile title /tmp/issue-title.txt \
|
||||
--rawfile body /tmp/issue-body.txt \
|
||||
--rawfile fields /tmp/issue-fields.json \
|
||||
--rawfile files /tmp/all-source-files.txt \
|
||||
'{
|
||||
model: "anthropic/claude-opus-4.6",
|
||||
model: $model,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a file selector for Donut Browser (Tauri + Next.js + Rust anti-detect browser). Given an issue and a list of source files, output ONLY the 10 most likely relevant file paths, one per line. No explanations, no numbering, just paths."
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: ("Issue: " + $title + "\n\n" + $body + "\n\nFiles:\n" + $files)
|
||||
}
|
||||
{ role: "system", content: $system_prompt },
|
||||
{ role: "user",
|
||||
content: ("Issue title: " + $title + "\n\nBody:\n" + $body + "\n\nParsed template fields:\n" + $fields + "\n\nAll source files:\n" + $files) }
|
||||
]
|
||||
}')
|
||||
|
||||
@@ -84,65 +254,167 @@ jobs:
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")
|
||||
|
||||
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/selected-files.txt
|
||||
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/triage-raw.txt
|
||||
|
||||
# Read the selected files in full (skip binary files)
|
||||
echo "" > /tmp/file-contents.txt
|
||||
while IFS= read -r filepath; do
|
||||
# Strip ```json fences if the model couldn't help itself.
|
||||
sed -E 's/^```(json)?$//; s/```$//' /tmp/triage-raw.txt > /tmp/triage.json
|
||||
|
||||
# Validate; if the model returned junk, fall back to a minimal stub so the
|
||||
# composer still gets called and produces SOMETHING.
|
||||
if ! jq -e . /tmp/triage.json >/dev/null 2>&1; then
|
||||
echo "::warning::Triage returned non-JSON; using fallback classification"
|
||||
cat /tmp/triage-raw.txt
|
||||
jq -n '{
|
||||
language: "en",
|
||||
classification: "bug-in-scope",
|
||||
operating_system: "unknown",
|
||||
is_paid_feature: false,
|
||||
user_followed_template: true,
|
||||
regression_signal: null,
|
||||
user_cited_external_docs: null,
|
||||
files_to_read: [],
|
||||
notes: "triage call failed; defaulting"
|
||||
}' > /tmp/triage.json
|
||||
fi
|
||||
|
||||
echo "Triage result:"
|
||||
cat /tmp/triage.json
|
||||
|
||||
- name: Read files chosen by triage
|
||||
run: |
|
||||
: > /tmp/file-context.txt
|
||||
# files_to_read may be empty (e.g. upstream Camoufox) — that's fine.
|
||||
jq -r '.files_to_read[]? // empty' /tmp/triage.json | while IFS= read -r filepath; do
|
||||
filepath=$(echo "$filepath" | xargs)
|
||||
[ -z "$filepath" ] && continue
|
||||
# Reject paths that escape the repo or look fishy
|
||||
case "$filepath" in
|
||||
/*|*..*|*$'\n'*) continue ;;
|
||||
esac
|
||||
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
|
||||
echo "=== $filepath ===" >> /tmp/file-contents.txt
|
||||
cat "$filepath" >> /tmp/file-contents.txt
|
||||
echo "" >> /tmp/file-contents.txt
|
||||
echo "=== $filepath ===" >> /tmp/file-context.txt
|
||||
cat "$filepath" >> /tmp/file-context.txt
|
||||
echo "" >> /tmp/file-context.txt
|
||||
fi
|
||||
done < /tmp/selected-files.txt
|
||||
done
|
||||
# Cap total context at 100 KB to keep token cost bounded.
|
||||
head -c 100000 /tmp/file-context.txt > /tmp/file-context.capped.txt
|
||||
mv /tmp/file-context.capped.txt /tmp/file-context.txt
|
||||
wc -c /tmp/file-context.txt
|
||||
|
||||
# Cap total context at 100KB
|
||||
head -c 100000 /tmp/file-contents.txt > /tmp/file-context.txt
|
||||
- name: Build composer system prompt
|
||||
run: |
|
||||
# Same reason as the triage prompt: lots of apostrophes, no shell-quoting
|
||||
# gymnastics. Build it to a file, load via --rawfile.
|
||||
{
|
||||
cat <<'COMPOSER_HEAD'
|
||||
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.
|
||||
|
||||
- name: Analyze issue with AI
|
||||
COMPOSER_HEAD
|
||||
cat /tmp/scope-and-pricing.md
|
||||
printf '\n\n# REPO GUIDELINES\n'
|
||||
cat /tmp/repo-context.txt
|
||||
cat <<'COMPOSER_TAIL'
|
||||
|
||||
# RULES — STRICT
|
||||
|
||||
## Output shape
|
||||
- One sentence acknowledging the report.
|
||||
- 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.
|
||||
- Maximum 15 lines.
|
||||
- No labels, no `Label:` line, no markdown headings other than `**Missing information**`.
|
||||
- No closing pleasantries ("please let me know", "happy to help", etc.).
|
||||
|
||||
## Forbidden — never do these
|
||||
- NEVER include a `Possible cause` / `Likely cause` / `Root cause` / `Probably caused by` section. You do not have enough information; speculation is always wrong here.
|
||||
- NEVER cite internal file paths or line numbers in the comment. Internal references rot and confuse non-developers.
|
||||
- NEVER reference how subscription / paid-plan checks work internally. You do not know whether the user's claim is correct.
|
||||
- NEVER call a report "well-documented", "well-structured", "clear", "thorough", "reasonable", "well-thought-out", or any similar evaluation. You are triage, not peer review.
|
||||
- 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.
|
||||
- NEVER validate a feature request as "a clear enhancement" / "a reasonable request" / similar. Acknowledge neutrally and ask only the missing info (use case, urgency).
|
||||
- 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.
|
||||
|
||||
## Classification handling
|
||||
The triage classification (`triage.classification`) determines the response shape:
|
||||
|
||||
- `bug-in-scope`: ask for what is missing using the user's reported OS log path. Be concrete about how to obtain logs.
|
||||
- `bug-upstream-camoufox`: redirect ONLY. One sentence acknowledging, then a sentence saying this is a Camoufox-internal issue and the maintainer of this repo does not contribute to Camoufox; ask the user to file at https://github.com/daijro/camoufox/issues. Do NOT ask for Donut logs. Stop after that.
|
||||
- `bug-template-violation` or `ai-generated-junk`: politely ask the user to refile using the bug-report template (the Operating System, Donut Browser version, Which browser, Steps to reproduce, Error logs sections). If they cited "documentation" from any non-`donutbrowser.com`/non-`github.com/zhom` URL (e.g. context7, deepwiki), gently note that those are AI-generated third-party summaries and the only authoritative sources are this repo and donutbrowser.com.
|
||||
- `feature-request`: one neutral sentence acknowledging, then ask only what is genuinely needed (concrete use case, whether a workaround would suffice). Do NOT validate.
|
||||
- `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.
|
||||
- `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.
|
||||
- `question`: answer briefly if obvious from repo guidelines / pricing; otherwise ask for clarification.
|
||||
|
||||
## Paid-feature awareness
|
||||
If `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.
|
||||
|
||||
## Language
|
||||
If 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.
|
||||
|
||||
## OS-specific log paths
|
||||
Use ONLY the one matching `triage.operating_system`:
|
||||
- macos: `~/Library/Logs/com.donutbrowser/`
|
||||
- linux: `~/.local/share/com.donutbrowser/logs/`
|
||||
- windows: `%APPDATA%\com.donutbrowser\logs\` (PowerShell-friendly: `Get-ChildItem $env:APPDATA\com.donutbrowser\logs`)
|
||||
- unknown: ask the user to share their OS first.
|
||||
|
||||
## Known error signatures (apply BEFORE asking generic questions)
|
||||
If the issue body contains any of these, lead with the matching
|
||||
response — do NOT speculate about other causes:
|
||||
|
||||
- `CDP not ready after N attempts on port X: HTTP 5xx ...` —
|
||||
this is loopback interception by a firewall / antivirus
|
||||
web-shield / VPN / MDM. Lead with that question (specifically:
|
||||
Kaspersky, Bitdefender, ESET, Avast/AVG, Yandex Protect on
|
||||
Windows; Little Snitch, LuLu, corporate MDM on macOS; any
|
||||
VPN). Suggest temporarily disabling the AV's web-shield
|
||||
component (NOT the whole AV) and retrying. Do NOT mention
|
||||
Gatekeeper, first-launch verification, code signing, or
|
||||
quarantine — none of those cause an HTTP 5xx response, and
|
||||
Gatekeeper never delays a launch long enough to produce a
|
||||
"120 attempts" failure.
|
||||
COMPOSER_TAIL
|
||||
} > /tmp/composer-system.txt
|
||||
wc -c /tmp/composer-system.txt
|
||||
|
||||
- name: Stage 2 — Compose response
|
||||
env:
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
|
||||
IS_FIRST_TIME: ${{ steps.check-first-time.outputs.is_first_time }}
|
||||
run: |
|
||||
GREETING=""
|
||||
if [ "$IS_FIRST_TIME" = "true" ]; then
|
||||
GREETING='This is a first-time contributor. Start your comment with: "Thanks for opening your first issue!"'
|
||||
# Use printf with %s so the apostrophe inside the string never has to
|
||||
# cross a shell single-quote boundary.
|
||||
printf '%s' 'This is the first issue from this user — start the comment with "Thanks for opening your first issue!" on its own line.' > /tmp/greeting.txt
|
||||
else
|
||||
: > /tmp/greeting.txt
|
||||
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
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg model "$COMPOSER_MODEL" \
|
||||
--rawfile system_prompt /tmp/composer-system.txt \
|
||||
--rawfile title /tmp/issue-title.txt \
|
||||
--rawfile body /tmp/issue-body.txt \
|
||||
--rawfile author /tmp/issue-author.txt \
|
||||
--rawfile fields /tmp/issue-fields.json \
|
||||
--rawfile triage /tmp/triage.json \
|
||||
--rawfile greeting /tmp/greeting.txt \
|
||||
--rawfile repo_context /tmp/repo-context.txt \
|
||||
--rawfile context /tmp/file-context.txt \
|
||||
--rawfile files /tmp/file-context.txt \
|
||||
'{
|
||||
model: "anthropic/claude-opus-4.6",
|
||||
model: $model,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: ("You are a triage bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nYou have access to relevant source files for context.\n\nAnalyze the issue and produce a single comment. Your job is to collect missing information needed to diagnose the issue, NOT to guess the cause.\n\nFormat:\n\n1. One sentence acknowledging the issue.\n2. **Missing information** - Ask specific questions about what is missing from the report. Focus on reproducing the issue. Do NOT speculate about root causes or mention internal code/files — you will almost certainly be wrong without logs. Instead, ask for:\n - Exact steps to reproduce (if not provided)\n - Expected vs actual behavior (if unclear)\n - Error messages or screenshots (if not provided)\n - OS and app version (if not provided)\n - For bug reports: if logs are needed, tell the user EXACTLY how to get them:\n - macOS app logs: `~/Library/Logs/Donut Browser/`\n - Linux app logs: `~/.local/share/DonutBrowser/logs/`\n - Windows app logs: `%APPDATA%\\DonutBrowser\\logs\\`\n - Sync server logs: `docker logs <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.")
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: (
|
||||
(if ($greeting | length) > 0 then $greeting + "\n\n" else "" end) +
|
||||
"Analyze this issue:\n\nTitle: " + $title +
|
||||
"\nAuthor: " + $author +
|
||||
"\n\nBody:\n" + $body +
|
||||
"\n\nRelevant source files:\n" + $context
|
||||
)
|
||||
}
|
||||
{ role: "system", content: $system_prompt },
|
||||
{ role: "user",
|
||||
content: ((if ($greeting | length) > 0 then $greeting + "\n\n" else "" end)
|
||||
+ "Title: " + $title
|
||||
+ "\nAuthor: " + $author
|
||||
+ "\n\n## Triage result\n" + $triage
|
||||
+ "\n\n## Parsed template fields\n" + $fields
|
||||
+ "\n\n## Raw issue body\n" + $body
|
||||
+ "\n\n## Source files (selected by triage)\n" + $files) }
|
||||
]
|
||||
}')
|
||||
|
||||
@@ -154,28 +426,41 @@ jobs:
|
||||
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/ai-comment.txt
|
||||
|
||||
if [ ! -s /tmp/ai-comment.txt ]; then
|
||||
echo "::error::AI response was empty"
|
||||
echo "::error::Composer returned empty response"
|
||||
echo "Raw response:"
|
||||
echo "$RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Post comment and label
|
||||
- name: Strip forbidden sections (defense in depth)
|
||||
run: |
|
||||
# Even with explicit prompt rules, LLMs sometimes still emit "Possible cause"
|
||||
# and friends. Strip any such heading + its block. Also drop any stray
|
||||
# `Label:` lines from earlier prompt iterations.
|
||||
python3 - <<'EOF'
|
||||
import re
|
||||
path = '/tmp/ai-comment.txt'
|
||||
text = open(path).read()
|
||||
# Drop forbidden section headers and everything until a blank line or another header.
|
||||
forbidden = re.compile(
|
||||
r'^\s*\**\s*(?:possible|likely|root|probable)\s+cause\b.*?(?=^\s*$|\n##|\n\*\*[A-Z]|\Z)',
|
||||
re.IGNORECASE | re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
text = forbidden.sub('', text)
|
||||
# Drop stale Label: lines (we don't label anymore).
|
||||
text = re.sub(r'^\s*Label:\s*.*$', '', text, flags=re.MULTILINE)
|
||||
# Collapse 3+ blank lines.
|
||||
text = re.sub(r'\n{3,}', '\n\n', text).strip() + '\n'
|
||||
open(path, 'w').write(text)
|
||||
EOF
|
||||
|
||||
- name: Post comment (no labeling)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
LABEL=$(grep -oP '^Label:\s*\K.*' /tmp/ai-comment.txt | tail -1 | tr '[:upper:]' '[:lower:]' | xargs)
|
||||
sed -i '/^Label:/d' /tmp/ai-comment.txt
|
||||
|
||||
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
|
||||
|
||||
if [ "$LABEL" = "bug" ]; then
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "bug" 2>/dev/null || true
|
||||
elif [ "$LABEL" = "enhancement" ]; then
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "enhancement" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
analyze-pr:
|
||||
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -204,26 +489,20 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
# Get changed files list
|
||||
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" \
|
||||
--jq '.[] | "- \(.filename) (\(.status)) +\(.additions)/-\(.deletions)"' \
|
||||
> /tmp/pr-files.txt
|
||||
|
||||
# Get the actual diff
|
||||
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \
|
||||
--header "Accept: application/vnd.github.diff" \
|
||||
> /tmp/pr-diff-full.txt 2>/dev/null || true
|
||||
head -c 20000 /tmp/pr-diff-full.txt > /tmp/pr-diff.txt
|
||||
|
||||
# Get CONTRIBUTING.md and README.md for context
|
||||
cat CONTRIBUTING.md > /tmp/contributing.txt 2>/dev/null || echo "Not found" > /tmp/contributing.txt
|
||||
head -50 README.md > /tmp/readme.txt 2>/dev/null || echo "Not found" > /tmp/readme.txt
|
||||
|
||||
# Read project guidelines (contains repo structure)
|
||||
cp CLAUDE.md /tmp/repo-context.txt
|
||||
|
||||
# Read full contents of all changed files (skip binary)
|
||||
echo "" > /tmp/related-file-contents.txt
|
||||
: > /tmp/related-file-contents.txt
|
||||
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' | while IFS= read -r filepath; do
|
||||
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
|
||||
echo "=== $filepath (full file) ===" >> /tmp/related-file-contents.txt
|
||||
@@ -258,6 +537,7 @@ jobs:
|
||||
printf '%s' "$GREETING" > /tmp/greeting.txt
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg model "$COMPOSER_MODEL" \
|
||||
--rawfile title /tmp/pr-title.txt \
|
||||
--rawfile body /tmp/pr-body.txt \
|
||||
--rawfile author /tmp/pr-author.txt \
|
||||
@@ -270,7 +550,7 @@ jobs:
|
||||
--rawfile contributing /tmp/contributing.txt \
|
||||
--rawfile file_context /tmp/pr-file-context.txt \
|
||||
'{
|
||||
model: "anthropic/claude-opus-4.6",
|
||||
model: $model,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
@@ -327,7 +607,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@da6683fedcbb57a36c4ba54ba5ad00dd8bc2da65 #v1.14.24
|
||||
uses: anomalyco/opencode/github@8ba2a9171597262df9d19516c82a5e14f18f5c63 #v1.14.41
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
name: Notify Telegram
|
||||
|
||||
# tauri-action creates the release with the default GITHUB_TOKEN, and GitHub
|
||||
# Actions deliberately suppresses `release: published` events for releases
|
||||
# made by GITHUB_TOKEN (to prevent recursive workflow chains). So we can't
|
||||
# listen for `release: published` — it will never fire on stable releases.
|
||||
#
|
||||
# Instead, chain off the Release workflow via `workflow_run`, the same way
|
||||
# `publish-repos.yml` does. `workflow_dispatch` is kept so a missed
|
||||
# announcement can be replayed by hand.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to announce (e.g. v0.23.0). Leave empty for latest stable."
|
||||
required: false
|
||||
type: string
|
||||
workflow_run:
|
||||
workflows: ["Release"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
if: >
|
||||
github.repository == 'zhom/donutbrowser' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve release tag
|
||||
id: tag
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
# `head_branch` of a workflow_run trigger is attacker-influenceable
|
||||
# (anyone with push to a tag can choose its name), so we pass it via
|
||||
# env and validate before use rather than splicing it into the
|
||||
# shell script literally. See CodeQL actions/code-injection.
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
WORKFLOW_RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [[ -n "${INPUT_TAG:-}" ]]; then
|
||||
TAG="${INPUT_TAG}"
|
||||
elif [[ "${EVENT_NAME}" == "workflow_run" ]]; then
|
||||
# The Release workflow runs on `push: tags: v*` so head_branch
|
||||
# of the triggering run is the tag name. Reject anything that
|
||||
# isn't a plain tag-shaped string to keep this resistant to
|
||||
# shell metacharacters injected via a crafted ref name.
|
||||
if [[ ! "${WORKFLOW_RUN_HEAD_BRANCH}" =~ ^[A-Za-z0-9._/-]+$ ]]; then
|
||||
echo "::error::Refusing tag with unexpected characters: ${WORKFLOW_RUN_HEAD_BRANCH}"
|
||||
exit 1
|
||||
fi
|
||||
TAG="${WORKFLOW_RUN_HEAD_BRANCH}"
|
||||
else
|
||||
TAG=$(gh release view --repo "${REPO}" --json tagName -q .tagName)
|
||||
fi
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved tag: ${TAG}"
|
||||
|
||||
- name: Skip pre-releases / missing releases
|
||||
id: gate
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
run: |
|
||||
# Tag like `nightly-…` or `nightly` is never an announceable
|
||||
# stable release. Short-circuit before hitting the API.
|
||||
if [[ "${TAG}" == nightly* ]]; then
|
||||
echo "Tag '${TAG}' is a rolling/nightly build, skipping Telegram post."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only stable semver tags vX.Y.Z are eligible. Reject anything
|
||||
# with a pre-release suffix (`-rc1`, `-beta`, etc.).
|
||||
if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Tag '${TAG}' is not a stable semver tag, skipping."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Confirm the release exists and isn't marked prerelease in the
|
||||
# GitHub UI — guards against someone manually flipping the flag.
|
||||
RELEASE_JSON=$(gh release view "${TAG}" --repo "${{ github.repository }}" --json isPrerelease,tagName 2>/dev/null || echo "")
|
||||
if [[ -z "${RELEASE_JSON}" ]]; then
|
||||
echo "Release ${TAG} not found via gh — skipping."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
IS_PRE=$(jq -r .isPrerelease <<< "${RELEASE_JSON}")
|
||||
if [[ "${IS_PRE}" == "true" ]]; then
|
||||
echo "Release ${TAG} is marked prerelease, skipping."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Post release announcement to Telegram
|
||||
if: steps.gate.outputs.skip != 'true'
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||
echo "::warning::TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set — skipping Telegram notification."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find the previous stable tag (skip the current one) so the
|
||||
# changelog range is well-defined.
|
||||
PREV_TAG=$(git tag --sort=-version:refname \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| grep -v "^${TAG}$" \
|
||||
| head -n 1)
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
|
||||
fi
|
||||
|
||||
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
|
||||
|
||||
# Build a plain bullet list from feat / fix / refactor commits.
|
||||
# Other commit types (chore, docs, ci, test, deps) are intentionally
|
||||
# filtered out to keep the channel focused on user-visible changes.
|
||||
CHANGES=""
|
||||
while IFS= read -r msg; do
|
||||
[ -z "$msg" ] && continue
|
||||
case "$msg" in
|
||||
feat\(*\):*|feat:*|fix\(*\):*|fix:*|refactor\(*\):*|refactor:*)
|
||||
CHANGES="${CHANGES}• $(strip_prefix "$msg")"$'\n'
|
||||
;;
|
||||
esac
|
||||
done < <(git log --pretty=format:%s "${PREV_TAG}..${TAG}")
|
||||
|
||||
if [ -z "$CHANGES" ]; then
|
||||
CHANGES="• See release notes."$'\n'
|
||||
fi
|
||||
|
||||
# HTML-escape the changelog before injecting into Telegram HTML
|
||||
# mode — commit messages can legitimately contain `<`, `>`, `&`.
|
||||
ESCAPED_CHANGES=$(printf '%s' "$CHANGES" \
|
||||
| python3 -c "import html, sys; sys.stdout.write(html.escape(sys.stdin.read()))")
|
||||
|
||||
VERSION="${TAG}"
|
||||
VERSION_NUM="${TAG#v}"
|
||||
RELEASE_URL="https://github.com/${REPO}/releases/tag/${VERSION}"
|
||||
DL="https://github.com/${REPO}/releases/download/${VERSION}"
|
||||
|
||||
# Build the API payload in one jq pass — keeps every literal
|
||||
# newline, every angle bracket, and every quote correctly escaped
|
||||
# for both shell and JSON.
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg chat_id "$TELEGRAM_CHAT_ID" \
|
||||
--arg version "$VERSION" \
|
||||
--arg changes "$ESCAPED_CHANGES" \
|
||||
--arg dl "$DL" \
|
||||
--arg vnum "$VERSION_NUM" \
|
||||
--arg release_url "$RELEASE_URL" \
|
||||
'{
|
||||
chat_id: $chat_id,
|
||||
parse_mode: "HTML",
|
||||
disable_web_page_preview: true,
|
||||
text: (
|
||||
"<b>Donut Browser " + $version + " released</b>\n\n" +
|
||||
$changes + "\n" +
|
||||
"<b>Download</b>\n" +
|
||||
"<a href=\"" + $dl + "/Donut_" + $vnum + "_aarch64.dmg\">macOS (Apple Silicon)</a> · " +
|
||||
"<a href=\"" + $dl + "/Donut_" + $vnum + "_x64.dmg\">macOS (Intel)</a>\n" +
|
||||
"<a href=\"" + $dl + "/Donut_" + $vnum + "_x64-setup.exe\">Windows x64</a> · " +
|
||||
"<a href=\"" + $dl + "/Donut_" + $vnum + "_amd64.AppImage\">Linux x64</a>\n\n" +
|
||||
"<a href=\"" + $release_url + "\">Full release notes</a>"
|
||||
)
|
||||
}')
|
||||
|
||||
# Use --fail-with-body so we surface Telegram's error JSON on 4xx/5xx
|
||||
# instead of just a curl exit code.
|
||||
RESPONSE=$(curl -sSL --fail-with-body \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage") \
|
||||
|| { echo "::error::Telegram API call failed"; echo "$RESPONSE"; exit 1; }
|
||||
|
||||
if [ "$(jq -r .ok <<< "$RESPONSE")" != "true" ]; then
|
||||
echo "::error::Telegram API rejected the message:"
|
||||
jq . <<< "$RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Posted to Telegram (message_id $(jq -r .result.message_id <<< "$RESPONSE"))"
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -23,4 +23,4 @@ jobs:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@cf5f1c29a8ac336af8568821ec41919923b05a83 #v1.45.1
|
||||
uses: crate-ci/typos@5374cbf686e897b15713110e233094e2874de7ef #v1.46.1
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e #v6.0.3
|
||||
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 #v6.0.6
|
||||
with:
|
||||
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.
|
||||
- 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.
|
||||
- **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
|
||||
|
||||
|
||||
+113
@@ -1,6 +1,119 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## v0.24.0 (2026-05-12)
|
||||
|
||||
### Features
|
||||
|
||||
- support latest camoufox
|
||||
- full ui refresh
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- pass correct parameter for dns list selection
|
||||
|
||||
### Refactoring
|
||||
|
||||
- better error handling and prevention of creating ephemeral password protected profiles
|
||||
- ui cleanup
|
||||
- sync cleanup
|
||||
- proxy spawn
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: update dependencies
|
||||
- chore: fix telegram notifications
|
||||
- chore: fix issue validation
|
||||
- chore: update flake.nix for v0.23.0 [skip ci] (#351)
|
||||
|
||||
|
||||
## v0.23.0 (2026-05-10)
|
||||
|
||||
### Features
|
||||
|
||||
- password protected profiles
|
||||
- telegram notifications
|
||||
|
||||
### Refactoring
|
||||
|
||||
- reduce the number of s3 calls
|
||||
|
||||
### Documentation
|
||||
|
||||
- remove fossa badge
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: logging
|
||||
- chore: copy
|
||||
- chore: optimize issue validation
|
||||
- chore: linting
|
||||
- ci(deps): bump the github-actions group with 3 updates (#348)
|
||||
- chore: cleanup issue validation
|
||||
- chore: update flake.nix for v0.22.7 [skip ci] (#341)
|
||||
|
||||
### Other
|
||||
|
||||
- deps(rust)(deps): bump the rust-dependencies group (#349)
|
||||
- deps(rust)(deps): bump tauri from 2.11.0 to 2.11.1 in /src-tauri (#346)
|
||||
- deps(rust)(deps): bump openssl from 0.10.78 to 0.10.79 in /src-tauri
|
||||
|
||||
|
||||
## v0.22.7 (2026-05-05)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- cleanup
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: copy
|
||||
- chore: update flake.nix for v0.22.6 [skip ci] (#337)
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
@@ -16,9 +16,6 @@
|
||||
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/blob/main/LICENSE" target="_blank">
|
||||
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
|
||||
</a>
|
||||
<a href="https://app.fossa.com/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser?ref=badge_shield&issueType=security" alt="FOSSA Status">
|
||||
<img src="https://app.fossa.com/api/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser.svg?type=shield&issueType=security" alt="FOSSA Security Status"/>
|
||||
</a>
|
||||
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/network/members" target="_blank">
|
||||
<img src="https://img.shields.io/github/forks/zhom/donutbrowser?style=social" alt="GitHub forks">
|
||||
</a>
|
||||
@@ -51,7 +48,7 @@
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.4/Donut_0.22.4_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.4/Donut_0.22.4_x64.dmg) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
@@ -61,15 +58,15 @@ brew install --cask donut
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.4/Donut_0.22.4_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.4/Donut_0.22.4_x64-portable.zip)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.4/Donut_0.22.4_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.4/Donut_0.22.4_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.4/Donut-0.22.4-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.4/Donut-0.22.4-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.4/Donut_0.22.4_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.4/Donut_0.22.4_aarch64.AppImage) |
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut-0.24.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut-0.24.0-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
@@ -160,6 +157,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
<br />
|
||||
<sub><b>Jory Severijnse</b></sub>
|
||||
</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>
|
||||
</tr>
|
||||
<tbody>
|
||||
|
||||
+12
-12
@@ -18,33 +18,33 @@
|
||||
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1024.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1024.0",
|
||||
"@nestjs/common": "^11.1.18",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.1.18",
|
||||
"@nestjs/platform-express": "^11.1.18",
|
||||
"@aws-sdk/client-s3": "^3.1045.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
||||
"@nestjs/common": "^11.1.19",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.19",
|
||||
"@nestjs/platform-express": "^11.1.19",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.17",
|
||||
"@nestjs/schematics": "^11.0.10",
|
||||
"@nestjs/testing": "^11.1.18",
|
||||
"@nestjs/cli": "^11.0.21",
|
||||
"@nestjs/schematics": "^11.1.0",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"jest": "^30.3.0",
|
||||
"jest": "^30.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.9",
|
||||
"ts-loader": "^9.5.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^6.0.2"
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
||||
@@ -117,7 +117,7 @@ export class SyncController {
|
||||
@Get("subscribe")
|
||||
@Sse()
|
||||
subscribe(@Req() req: Request): Observable<MessageEvent> {
|
||||
return this.syncService.subscribe(this.getUserContext(req), 2000).pipe(
|
||||
return this.syncService.subscribe(this.getUserContext(req), 5000).pipe(
|
||||
map((event) => ({
|
||||
data: event,
|
||||
})),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
DeleteObjectCommand,
|
||||
@@ -41,6 +42,18 @@ import type {
|
||||
SubscribeEventDto,
|
||||
} from "./dto/sync.dto.js";
|
||||
|
||||
/**
|
||||
* Marker object written under each scope (user / team / self-hosted root).
|
||||
* Subscribers HEAD this object on each poll and only LIST when its ETag has
|
||||
* changed, which keeps the steady-state polling cost down to one Class-B
|
||||
* HeadObject per scope per poll instead of N Class-A ListObjectsV2 calls.
|
||||
*
|
||||
* Filename starts with a dot so it sorts first and is unmistakably internal
|
||||
* to donut-sync; client `list()` calls strip it from results so it never
|
||||
* leaks into application data.
|
||||
*/
|
||||
const MANIFEST_KEY = ".donut-sync-manifest";
|
||||
|
||||
@Injectable()
|
||||
export class SyncService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SyncService.name);
|
||||
@@ -149,6 +162,71 @@ export class SyncService implements OnModuleInit {
|
||||
return `${ctx.prefix}${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return every scope prefix the given user can write to. For self-hosted
|
||||
* that's the bucket root (`""`); for cloud that's the user prefix plus an
|
||||
* optional team prefix.
|
||||
*/
|
||||
private scopesFor(ctx: UserContext): string[] {
|
||||
if (ctx.mode === "self-hosted") return [""];
|
||||
const out = [ctx.prefix];
|
||||
if (ctx.teamPrefix) out.push(ctx.teamPrefix);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump the manifest object for the scope that owns `scopedKey`. Writers call
|
||||
* this fire-and-forget after any successful mutation so subscribers'
|
||||
* cheap HEAD polls observe an ETag change and pull a fresh listing.
|
||||
*
|
||||
* Slightly over-eager by design: we bump on presign-issue (rather than on
|
||||
* the actual S3 PUT), so a never-completed upload causes one wasted refresh
|
||||
* on other devices. That's strictly cheaper than verifying every upload.
|
||||
*/
|
||||
private async bumpManifest(
|
||||
ctx: UserContext,
|
||||
scopedKey: string,
|
||||
): Promise<void> {
|
||||
const scope = this.scopeForKey(ctx, scopedKey);
|
||||
if (scope === null) return;
|
||||
const key = `${scope}${MANIFEST_KEY}`;
|
||||
// Body just needs to be unique so the ETag changes; clients never read it.
|
||||
const body = JSON.stringify({
|
||||
updatedAt: new Date().toISOString(),
|
||||
nonce: randomUUID(),
|
||||
});
|
||||
try {
|
||||
await this.s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: "application/json",
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
// Manifest bump failures must NEVER fail the user's request.
|
||||
// Subscribers fall back to detecting changes on their next listing.
|
||||
this.logger.warn(
|
||||
`Manifest bump failed for ${key}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which scope owns a fully-scoped key. Returns null if the key
|
||||
* doesn't belong to a known scope (which shouldn't happen in practice
|
||||
* because validateKeyAccess gates the write paths).
|
||||
*/
|
||||
private scopeForKey(ctx: UserContext, scopedKey: string): string | null {
|
||||
if (ctx.mode === "self-hosted") return "";
|
||||
if (ctx.teamPrefix && scopedKey.startsWith(ctx.teamPrefix)) {
|
||||
return ctx.teamPrefix;
|
||||
}
|
||||
if (scopedKey.startsWith(ctx.prefix)) return ctx.prefix;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a key is accessible by the user.
|
||||
* For cloud mode, key must start with user's prefix or team prefix.
|
||||
@@ -220,6 +298,11 @@ export class SyncService implements OnModuleInit {
|
||||
this.reportProfileUsageAsync(ctx);
|
||||
}
|
||||
|
||||
// Notify subscribers via the per-scope manifest. Fire-and-forget; a
|
||||
// failure here just means other devices pick up the change on their
|
||||
// next full listing instead of immediately.
|
||||
void this.bumpManifest(ctx, key);
|
||||
|
||||
return {
|
||||
url,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
@@ -294,6 +377,10 @@ export class SyncService implements OnModuleInit {
|
||||
this.reportProfileUsageAsync(ctx);
|
||||
}
|
||||
|
||||
if (deleted || tombstoneCreated) {
|
||||
void this.bumpManifest(ctx, key);
|
||||
}
|
||||
|
||||
return { deleted, tombstoneCreated };
|
||||
}
|
||||
|
||||
@@ -311,19 +398,22 @@ export class SyncService implements OnModuleInit {
|
||||
|
||||
const userPrefix = ctx?.prefix || "";
|
||||
const teamPrefix = ctx?.teamPrefix || "";
|
||||
const objects = (response.Contents || []).map((obj) => {
|
||||
let key = obj.Key || "";
|
||||
if (teamPrefix && key.startsWith(teamPrefix)) {
|
||||
key = key.substring(teamPrefix.length);
|
||||
} else if (userPrefix && key.startsWith(userPrefix)) {
|
||||
key = key.substring(userPrefix.length);
|
||||
}
|
||||
return {
|
||||
key,
|
||||
lastModified: obj.LastModified?.toISOString() || "",
|
||||
size: obj.Size || 0,
|
||||
};
|
||||
});
|
||||
const objects = (response.Contents || [])
|
||||
// Don't leak donut-sync's internal manifest object to clients.
|
||||
.filter((obj) => !(obj.Key || "").endsWith(MANIFEST_KEY))
|
||||
.map((obj) => {
|
||||
let key = obj.Key || "";
|
||||
if (teamPrefix && key.startsWith(teamPrefix)) {
|
||||
key = key.substring(teamPrefix.length);
|
||||
} else if (userPrefix && key.startsWith(userPrefix)) {
|
||||
key = key.substring(userPrefix.length);
|
||||
}
|
||||
return {
|
||||
key,
|
||||
lastModified: obj.LastModified?.toISOString() || "",
|
||||
size: obj.Size || 0,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
objects,
|
||||
@@ -373,6 +463,20 @@ export class SyncService implements OnModuleInit {
|
||||
this.reportProfileUsageAsync(ctx);
|
||||
}
|
||||
|
||||
// One bump per scope touched by this batch (usually one).
|
||||
if (items.length > 0) {
|
||||
const scopesSeen = new Set<string>();
|
||||
for (const item of dto.items) {
|
||||
const key = this.scopeKey(ctx, item.key);
|
||||
const scope = this.scopeForKey(ctx, key);
|
||||
if (scope !== null && !scopesSeen.has(scope)) {
|
||||
scopesSeen.add(scope);
|
||||
// Use any key from the scope; bumpManifest only inspects scope.
|
||||
void this.bumpManifest(ctx, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
@@ -475,66 +579,154 @@ export class SyncService implements OnModuleInit {
|
||||
this.reportProfileUsageAsync(ctx);
|
||||
}
|
||||
|
||||
if (deletedCount > 0 || tombstoneCreated) {
|
||||
void this.bumpManifest(ctx, prefix);
|
||||
}
|
||||
|
||||
return { deletedCount, tombstoneCreated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Long-lived per-client poll loop.
|
||||
*
|
||||
* Steady-state cost is one HEAD per scope per poll (Class B on R2). A LIST
|
||||
* (Class A) is only issued when:
|
||||
* 1. it's the client's first poll (need to seed the state map), or
|
||||
* 2. a write touched the scope and bumped its manifest ETag.
|
||||
*
|
||||
* This is *eventual* cross-device sync, gated by the poll interval.
|
||||
* Real-time push is intentionally not provided here — that lives in the
|
||||
* paid backend.
|
||||
*/
|
||||
subscribe(
|
||||
ctx: UserContext,
|
||||
pollIntervalMs = 2000,
|
||||
pollIntervalMs = 5000,
|
||||
): Observable<SubscribeEventDto> {
|
||||
const basePrefixes = ["profiles/", "proxies/", "groups/", "tombstones/"];
|
||||
const scopes = this.scopesFor(ctx);
|
||||
|
||||
let prefixes: string[];
|
||||
if (ctx.mode === "self-hosted") {
|
||||
prefixes = basePrefixes;
|
||||
} else {
|
||||
prefixes = basePrefixes.map((p) => `${ctx.prefix}${p}`);
|
||||
if (ctx.teamPrefix) {
|
||||
prefixes.push(...basePrefixes.map((p) => `${ctx.teamPrefix}${p}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Per-connection state (not shared across subscribers)
|
||||
// Per-connection state (not shared across subscribers).
|
||||
const lastManifestEtag = new Map<string, string | undefined>();
|
||||
let lastKnownState = new Map<string, string>();
|
||||
let initialized = false;
|
||||
|
||||
const pollChanges$ = interval(pollIntervalMs).pipe(
|
||||
startWith(0),
|
||||
switchMap(async () => {
|
||||
const events: SubscribeEventDto[] = [];
|
||||
const currentState = new Map<string, string>();
|
||||
|
||||
for (const prefix of prefixes) {
|
||||
// Phase 1 — cheap HEAD on each scope's manifest. This is the
|
||||
// steady-state cost (Class B). If no manifest changed since the
|
||||
// last poll, we don't touch S3 again this tick.
|
||||
let anyScopeChanged = false;
|
||||
for (const scope of scopes) {
|
||||
const manifestKey = `${scope}${MANIFEST_KEY}`;
|
||||
let currentEtag: string | undefined;
|
||||
try {
|
||||
const result = await this.list({ prefix, maxKeys: 1000 });
|
||||
for (const obj of result.objects) {
|
||||
const stateKey = `${obj.key}:${obj.lastModified}`;
|
||||
currentState.set(obj.key, stateKey);
|
||||
|
||||
const previousStateKey = lastKnownState.get(obj.key);
|
||||
if (previousStateKey !== stateKey) {
|
||||
events.push({
|
||||
type: "change",
|
||||
key: obj.key,
|
||||
lastModified: obj.lastModified,
|
||||
size: obj.size,
|
||||
});
|
||||
}
|
||||
const head = await this.s3Client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: manifestKey,
|
||||
}),
|
||||
);
|
||||
currentEtag = head.ETag;
|
||||
} catch (err: unknown) {
|
||||
const status =
|
||||
err && typeof err === "object" && "$metadata" in err
|
||||
? (err as { $metadata?: { httpStatusCode?: number } }).$metadata
|
||||
?.httpStatusCode
|
||||
: undefined;
|
||||
const name =
|
||||
err && typeof err === "object" && "name" in err
|
||||
? (err as { name?: string }).name
|
||||
: undefined;
|
||||
if (name === "NotFound" || name === "NoSuchKey" || status === 404) {
|
||||
// No manifest yet — treat as "no changes" (undefined ETag).
|
||||
currentEtag = undefined;
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Manifest HEAD failed for ${manifestKey}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to list prefix ${prefix}:`, error);
|
||||
}
|
||||
|
||||
const previousEtag = lastManifestEtag.get(scope);
|
||||
if (previousEtag !== currentEtag) {
|
||||
anyScopeChanged = true;
|
||||
}
|
||||
lastManifestEtag.set(scope, currentEtag);
|
||||
}
|
||||
|
||||
// After the first poll, only run the LIST when something actually
|
||||
// changed in at least one scope.
|
||||
if (initialized && !anyScopeChanged) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Phase 2 — one LIST per scope (not per base prefix). Filter to the
|
||||
// four base prefixes client-side. This is the cost we pay only when
|
||||
// a manifest told us there's something new to look at.
|
||||
const currentState = new Map<string, string>();
|
||||
for (const scope of scopes) {
|
||||
let continuationToken: string | undefined;
|
||||
do {
|
||||
try {
|
||||
const result = await this.s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: this.bucket,
|
||||
Prefix: scope,
|
||||
MaxKeys: 1000,
|
||||
ContinuationToken: continuationToken,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const obj of result.Contents || []) {
|
||||
const fullKey = obj.Key;
|
||||
if (!fullKey) continue;
|
||||
const relativeKey = fullKey.startsWith(scope)
|
||||
? fullKey.substring(scope.length)
|
||||
: fullKey;
|
||||
// Skip the manifest object itself + anything outside the
|
||||
// four data prefixes.
|
||||
if (relativeKey === MANIFEST_KEY) continue;
|
||||
if (!basePrefixes.some((bp) => relativeKey.startsWith(bp))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastModified = obj.LastModified?.toISOString() || "";
|
||||
const stateKey = `${relativeKey}:${lastModified}`;
|
||||
currentState.set(relativeKey, stateKey);
|
||||
|
||||
const previousStateKey = lastKnownState.get(relativeKey);
|
||||
if (previousStateKey !== stateKey) {
|
||||
events.push({
|
||||
type: "change",
|
||||
key: relativeKey,
|
||||
lastModified,
|
||||
size: obj.Size || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
continuationToken = result.NextContinuationToken;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`List failed for scope '${scope}': ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
continuationToken = undefined;
|
||||
}
|
||||
} while (continuationToken);
|
||||
}
|
||||
|
||||
// Detect deletes by comparing key sets.
|
||||
for (const [key] of lastKnownState) {
|
||||
if (!currentState.has(key)) {
|
||||
events.push({
|
||||
type: "delete",
|
||||
key,
|
||||
});
|
||||
events.push({ type: "delete", key });
|
||||
}
|
||||
}
|
||||
|
||||
lastKnownState = currentState;
|
||||
initialized = true;
|
||||
return events;
|
||||
}),
|
||||
switchMap((events) => of(...events)),
|
||||
|
||||
@@ -94,17 +94,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.22.4";
|
||||
releaseVersion = "0.24.0";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.4/Donut_0.22.4_amd64.AppImage";
|
||||
hash = "sha256-sYYXHIBTj8hYEBytkOJXknbBJ80RZM4tGBLZq7ys5ug=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_amd64.AppImage";
|
||||
hash = "sha256-tidp6JvFPCbsPzZldeG4697dzQjhYv83DouzgxS+lKY=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.4/Donut_0.22.4_aarch64.AppImage";
|
||||
hash = "sha256-vRCFM2Vni3TKXUJpem8DocPNRxtqCKSSxF2O3cKveNs=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.24.0/Donut_0.24.0_aarch64.AppImage";
|
||||
hash = "sha256-9kHwDafQ+UsKeOeJ+7DbXGGeugogn+NjnhUBYxUeUUo=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
+27
-24
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.22.4",
|
||||
"version": "0.24.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 12341",
|
||||
@@ -16,7 +16,7 @@
|
||||
"lint:js": "biome check src/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
|
||||
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
|
||||
"lint:spell": "typos .",
|
||||
"tauri": "tauri",
|
||||
"tauri": "node scripts/run-with-env.mjs tauri",
|
||||
"shadcn:add": "pnpm dlx shadcn@latest add",
|
||||
"prepare": "husky && husky install",
|
||||
"format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
|
||||
@@ -45,58 +45,61 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tauri-apps/api": "~2.10.1",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-fs": "~2.5.0",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@tauri-apps/api": "~2.11.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.9",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "~2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"ahooks": "^3.9.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"color": "^5.0.3",
|
||||
"flag-icons": "^7.5.0",
|
||||
"i18next": "^26.0.3",
|
||||
"lucide-react": "^1.7.0",
|
||||
"i18next": "^26.1.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"motion": "^12.38.0",
|
||||
"next": "^16.2.3",
|
||||
"next": "^16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.7",
|
||||
"react-icons": "^5.6.0",
|
||||
"recharts": "3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.10",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@tauri-apps/cli": "~2.10.1",
|
||||
"@biomejs/biome": "2.4.15",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@tauri-apps/cli": "~2.11.1",
|
||||
"@types/color": "^4.2.1",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.4.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"lint-staged": "^17.0.4",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"ts-unused-exports": "^11.0.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.2"
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"picomatch@>=4.0.0 <4.0.4": ">=4.0.4",
|
||||
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0",
|
||||
"postcss@<8.5.10": ">=8.5.12",
|
||||
"fast-xml-parser@<5.7.0": ">=5.7.2"
|
||||
"fast-xml-parser@<5.7.0": ">=5.7.2",
|
||||
"fast-uri@<3.1.2": ">=3.1.2",
|
||||
"fast-xml-builder@<1.2.0": ">=1.2.0"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"lint-staged": {
|
||||
"**/*.{js,jsx,ts,tsx,json,css}": [
|
||||
"biome check --fix"
|
||||
|
||||
Generated
+2317
-3027
File diff suppressed because it is too large
Load Diff
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);
|
||||
}
|
||||
});
|
||||
@@ -171,10 +171,21 @@ async function startMinio(minioBin) {
|
||||
|
||||
async function buildDonutSync() {
|
||||
log("Building donut-sync...");
|
||||
// `nest build` runs incremental tsc, which silently skips emit when
|
||||
// tsconfig.build.tsbuildinfo says nothing changed — even if dist/ was
|
||||
// wiped. Drop the cache so we always produce a fresh dist.
|
||||
const syncDir = path.join(ROOT_DIR, "donut-sync");
|
||||
await rm(path.join(syncDir, "tsconfig.build.tsbuildinfo"), {
|
||||
force: true,
|
||||
});
|
||||
await rm(path.join(syncDir, "dist"), { recursive: true, force: true });
|
||||
execSync("pnpm build", {
|
||||
cwd: path.join(ROOT_DIR, "donut-sync"),
|
||||
cwd: syncDir,
|
||||
stdio: process.env.VERBOSE ? "inherit" : "ignore",
|
||||
});
|
||||
if (!existsSync(path.join(syncDir, "dist", "main.js"))) {
|
||||
throw new Error("donut-sync build did not produce dist/main.js");
|
||||
}
|
||||
log("donut-sync built");
|
||||
}
|
||||
|
||||
|
||||
Generated
+538
-796
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.22.4"
|
||||
version = "0.24.1"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -44,6 +44,7 @@ tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-macos-permissions = "2"
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
@@ -51,7 +52,7 @@ directories = "6"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["native-tls", "json", "stream", "socks", "charset", "http2", "system-proxy"] }
|
||||
tokio = { version = "1", features = ["full", "sync"] }
|
||||
tokio-util = "0.7"
|
||||
sysinfo = "0.38"
|
||||
sysinfo = "0.39"
|
||||
lazy_static = "1.5"
|
||||
base64 = "0.22"
|
||||
libc = "0.2"
|
||||
@@ -102,7 +103,7 @@ serde_yaml = "0.9"
|
||||
thiserror = "2.0"
|
||||
regex-lite = "0.1"
|
||||
tempfile = "3"
|
||||
maxminddb = "0.27"
|
||||
maxminddb = "0.28"
|
||||
quick-xml = { version = "0.39", features = ["serialize"] }
|
||||
|
||||
# VPN support
|
||||
@@ -110,7 +111,7 @@ boringtun = "0.7"
|
||||
smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
|
||||
|
||||
# Daemon dependencies (tray icon)
|
||||
tray-icon = "0.22"
|
||||
tray-icon = "0.24"
|
||||
tao = "0.35"
|
||||
image = "0.25"
|
||||
dirs = "6"
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"macos-permissions:allow-request-camera-permission",
|
||||
"macos-permissions:allow-check-microphone-permission",
|
||||
"macos-permissions:allow-check-camera-permission",
|
||||
"log:default"
|
||||
"log:default",
|
||||
"clipboard-manager:default",
|
||||
"clipboard-manager:allow-write-text"
|
||||
]
|
||||
}
|
||||
|
||||
+123
-4
@@ -41,6 +41,7 @@ pub struct ApiProfile {
|
||||
pub tags: Vec<String>,
|
||||
pub is_running: bool,
|
||||
pub proxy_bypass_rules: Vec<String>,
|
||||
pub vpn_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
@@ -60,6 +61,7 @@ pub struct CreateProfileRequest {
|
||||
pub browser: String,
|
||||
pub version: String,
|
||||
pub proxy_id: Option<String>,
|
||||
pub vpn_id: Option<String>,
|
||||
pub launch_hook: Option<String>,
|
||||
pub release_type: Option<String>,
|
||||
#[schema(value_type = Object)]
|
||||
@@ -76,6 +78,7 @@ pub struct UpdateProfileRequest {
|
||||
pub browser: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub proxy_id: Option<String>,
|
||||
pub vpn_id: Option<String>,
|
||||
pub launch_hook: Option<String>,
|
||||
pub release_type: Option<String>,
|
||||
#[schema(value_type = Object)]
|
||||
@@ -140,6 +143,16 @@ struct ApiVpnResponse {
|
||||
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)]
|
||||
struct ImportVpnRequest {
|
||||
/// Raw WireGuard `.conf` file content
|
||||
@@ -357,6 +370,7 @@ impl ApiServer {
|
||||
.routes(routes!(get_proxy, update_proxy, delete_proxy))
|
||||
.routes(routes!(get_vpns, create_vpn))
|
||||
.routes(routes!(import_vpn))
|
||||
.routes(routes!(export_vpn))
|
||||
.routes(routes!(get_vpn, update_vpn, delete_vpn))
|
||||
.routes(routes!(get_extensions))
|
||||
.routes(routes!(delete_extension_api))
|
||||
@@ -387,6 +401,10 @@ impl ApiServer {
|
||||
.merge(v1_routes)
|
||||
.nest("/ws", ws_routes)
|
||||
.route("/openapi.json", get(move || async move { Json(api) }))
|
||||
// Outermost layer: logs every request so customer reports show what
|
||||
// their automation is actually calling, what the response status was,
|
||||
// and how long it took. Never logs request bodies or auth headers.
|
||||
.layer(middleware::from_fn(request_logging_middleware))
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state);
|
||||
|
||||
@@ -440,6 +458,8 @@ async fn auth_middleware(
|
||||
request: axum::extract::Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let path = request.uri().path().to_string();
|
||||
|
||||
// Get the Authorization header
|
||||
let auth_header = headers
|
||||
.get("Authorization")
|
||||
@@ -448,19 +468,31 @@ async fn auth_middleware(
|
||||
|
||||
let token = match auth_header {
|
||||
Some(token) => token,
|
||||
None => return Err(StatusCode::UNAUTHORIZED),
|
||||
None => {
|
||||
log::warn!("[api] Rejected {path}: missing Authorization header");
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
};
|
||||
|
||||
// Get the stored token
|
||||
let settings_manager = crate::settings_manager::SettingsManager::instance();
|
||||
let stored_token = match settings_manager.get_api_token(&state.app_handle).await {
|
||||
Ok(Some(stored_token)) => stored_token,
|
||||
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
|
||||
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
Ok(None) => {
|
||||
log::warn!(
|
||||
"[api] Rejected {path}: API server has no stored token (was the API toggled off?)"
|
||||
);
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[api] Failed to read stored API token: {e}");
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
};
|
||||
|
||||
// Compare tokens
|
||||
if token != stored_token {
|
||||
log::warn!("[api] Rejected {path}: token mismatch");
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@@ -468,6 +500,38 @@ async fn auth_middleware(
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
/// Logs every request: method, path, query, response status, duration.
|
||||
/// Skips Authorization header and request bodies entirely.
|
||||
async fn request_logging_middleware(request: axum::extract::Request, next: Next) -> Response {
|
||||
let method = request.method().clone();
|
||||
let path = request.uri().path().to_string();
|
||||
let query = request.uri().query().map(|q| q.to_string());
|
||||
let started = std::time::Instant::now();
|
||||
|
||||
let response = next.run(request).await;
|
||||
|
||||
let status = response.status();
|
||||
let elapsed_ms = started.elapsed().as_millis();
|
||||
|
||||
let level = if status.is_server_error() {
|
||||
log::Level::Error
|
||||
} else if status.is_client_error() {
|
||||
log::Level::Warn
|
||||
} else {
|
||||
log::Level::Info
|
||||
};
|
||||
|
||||
match query {
|
||||
Some(q) => log::log!(
|
||||
level,
|
||||
"[api] {method} {path}?{q} -> {status} ({elapsed_ms} ms)"
|
||||
),
|
||||
None => log::log!(level, "[api] {method} {path} -> {status} ({elapsed_ms} ms)"),
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
// Global API server instance
|
||||
lazy_static! {
|
||||
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
|
||||
@@ -542,6 +606,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
|
||||
tags: profile.tags.clone(),
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
|
||||
vpn_id: profile.vpn_id.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -598,6 +663,7 @@ async fn get_profile(
|
||||
tags: profile.tags.clone(),
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
|
||||
vpn_id: profile.vpn_id.clone(),
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
@@ -652,7 +718,7 @@ async fn create_profile(
|
||||
&request.version,
|
||||
request.release_type.as_deref().unwrap_or("stable"),
|
||||
request.proxy_id.clone(),
|
||||
None, // vpn_id
|
||||
request.vpn_id.clone(),
|
||||
camoufox_config,
|
||||
wayfern_config,
|
||||
request.group_id.clone(),
|
||||
@@ -700,6 +766,7 @@ async fn create_profile(
|
||||
tags: profile.tags,
|
||||
is_running: false,
|
||||
proxy_bypass_rules: profile.proxy_bypass_rules,
|
||||
vpn_id: profile.vpn_id,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -733,6 +800,12 @@ async fn update_profile(
|
||||
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
||||
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
|
||||
if let Some(new_name) = request.name {
|
||||
if profile_manager
|
||||
@@ -762,6 +835,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 {
|
||||
let normalized = if launch_hook.trim().is_empty() {
|
||||
None
|
||||
@@ -1308,6 +1396,37 @@ async fn get_vpn(
|
||||
.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(
|
||||
post,
|
||||
path = "/v1/vpns/import",
|
||||
|
||||
@@ -928,18 +928,35 @@ impl AppAutoUpdater {
|
||||
// Move new app to current location
|
||||
fs::rename(installer_path, ¤t_app_path)?;
|
||||
|
||||
// Remove quarantine attributes from the new app
|
||||
let _ = Command::new("xattr")
|
||||
.args([
|
||||
"-dr",
|
||||
"com.apple.quarantine",
|
||||
current_app_path.to_str().unwrap(),
|
||||
])
|
||||
.output();
|
||||
|
||||
let _ = Command::new("xattr")
|
||||
.args(["-cr", current_app_path.to_str().unwrap()])
|
||||
.output();
|
||||
// Remove the macOS quarantine attribute from the freshly-installed app
|
||||
// so Gatekeeper doesn't block its first launch — but only if it's
|
||||
// actually present. macOS Sequoia's App Management TCC fires on the
|
||||
// modify-class syscall regardless of whether anything is actually
|
||||
// modified, so we gate the call behind a read-only `getxattr` check.
|
||||
let needs_quarantine_removal = {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let path_c = CString::new(current_app_path.as_os_str().as_bytes()).ok();
|
||||
let attr_c = CString::new("com.apple.quarantine").ok();
|
||||
match (path_c, attr_c) {
|
||||
(Some(p), Some(a)) => {
|
||||
// SAFETY: getxattr with a null buffer is a read-only size query.
|
||||
let result =
|
||||
unsafe { libc::getxattr(p.as_ptr(), a.as_ptr(), std::ptr::null_mut(), 0, 0, 0) };
|
||||
result >= 0
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
if needs_quarantine_removal {
|
||||
let _ = Command::new("xattr")
|
||||
.args([
|
||||
"-dr",
|
||||
"com.apple.quarantine",
|
||||
current_app_path.to_str().unwrap(),
|
||||
])
|
||||
.output();
|
||||
}
|
||||
|
||||
// Clean up backup after successful installation
|
||||
let _ = fs::remove_dir_all(&backup_path);
|
||||
|
||||
@@ -108,6 +108,17 @@ pub fn dns_blocklist_dir() -> PathBuf {
|
||||
cache_dir().join("dns_blocklists")
|
||||
}
|
||||
|
||||
/// Resolve the directory that tauri-plugin-log writes to. Mirrors the
|
||||
/// `LogDir` target used in the plugin builder so the path matches what's
|
||||
/// actually on disk for this OS.
|
||||
pub fn log_dir<R: tauri::Runtime>(handle: &tauri::AppHandle<R>) -> PathBuf {
|
||||
use tauri::Manager;
|
||||
handle
|
||||
.path()
|
||||
.app_log_dir()
|
||||
.unwrap_or_else(|_| std::env::temp_dir())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static TEST_DATA_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
|
||||
|
||||
@@ -701,6 +701,8 @@ mod tests {
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,9 +82,14 @@ fn build_proxy_url(
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() {
|
||||
// Initialize logger to write to stderr (which will be redirected to file)
|
||||
// Initialize logger to write to stderr (which will be redirected to file).
|
||||
//
|
||||
// Default filter is Info — Debug pulls in reqwest/hyper internals which
|
||||
// make the per-worker log unreadable on a busy browser session and obscure
|
||||
// the actual lines we care about (binds, accept errors, upstream failures).
|
||||
// RUST_LOG=debug or RUST_LOG=donut_proxy=trace still works for deep dives.
|
||||
env_logger::Builder::from_default_env()
|
||||
.filter_level(log::LevelFilter::Debug)
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.format_timestamp_millis()
|
||||
.init();
|
||||
|
||||
@@ -343,8 +348,11 @@ async fn main() {
|
||||
// Set high priority so this process is killed last under resource pressure
|
||||
set_high_priority();
|
||||
|
||||
log::error!("Proxy worker starting, looking for config id: {}", id);
|
||||
log::error!("Process PID: {}", std::process::id());
|
||||
log::info!(
|
||||
"Proxy worker starting (pid {}, config id {})",
|
||||
std::process::id(),
|
||||
id
|
||||
);
|
||||
|
||||
// Retry config loading to handle file system race condition on Windows
|
||||
// where the config file may not be immediately visible after being written
|
||||
@@ -352,7 +360,7 @@ async fn main() {
|
||||
let mut attempts = 0;
|
||||
loop {
|
||||
if let Some(config) = get_proxy_config(id) {
|
||||
log::error!(
|
||||
log::info!(
|
||||
"Found config: id={}, port={:?}, upstream={}",
|
||||
config.id,
|
||||
config.local_port,
|
||||
@@ -369,20 +377,19 @@ async fn main() {
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
log::error!("Config {} not found yet, retrying ({}/10)...", id, attempts);
|
||||
log::debug!("Config {} not found yet, retrying ({}/10)...", id, attempts);
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
};
|
||||
|
||||
// Run the proxy server - this should never return (infinite loop)
|
||||
log::error!("Starting proxy server for config id: {}", id);
|
||||
log::info!("Starting proxy server for config id: {}", id);
|
||||
if let Err(e) = run_proxy_server(config).await {
|
||||
log::error!("Failed to run proxy server: {}", e);
|
||||
log::error!("Error details: {:?}", e);
|
||||
log::error!("Proxy server failed: {} ({:?})", e, e);
|
||||
process::exit(1);
|
||||
}
|
||||
// This should never be reached - run_proxy_server has an infinite loop
|
||||
log::error!("ERROR: Proxy server returned unexpectedly (this should never happen)");
|
||||
log::error!("Proxy server returned unexpectedly (this should never happen)");
|
||||
process::exit(1);
|
||||
} else {
|
||||
log::error!("Invalid action for proxy-worker. Use 'start'");
|
||||
|
||||
@@ -1218,6 +1218,8 @@ mod tests {
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
};
|
||||
|
||||
let path = profile.get_profile_data_path(&profiles_dir);
|
||||
|
||||
+183
-29
@@ -7,10 +7,78 @@ use crate::platform_browser;
|
||||
use crate::profile::{BrowserProfile, ProfileManager};
|
||||
use crate::proxy_manager::PROXY_MANAGER;
|
||||
use crate::wayfern_manager::{WayfernConfig, WayfernManager};
|
||||
use chrono::{Datelike, TimeZone, Utc};
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use sysinfo::System;
|
||||
|
||||
/// Fixed UTC hour at which Wayfern fingerprints rotate. Picked to land in a
|
||||
/// low-traffic window for the average user; everyone shares the same UTC
|
||||
/// instant so the value here doesn't track any one user's local schedule.
|
||||
const FINGERPRINT_ROLLOVER_HOUR_UTC: u32 = 4;
|
||||
|
||||
/// File name of the per-profile marker recording the last fingerprint
|
||||
/// refresh time. Lives at `<profiles_dir>/<profile_id>/.last-fp-refresh`
|
||||
/// and is excluded from cloud sync (see `sync::manifest`) so each device
|
||||
/// runs its own refresh schedule.
|
||||
const LAST_FP_REFRESH_FILE: &str = ".last-fp-refresh";
|
||||
|
||||
/// Most recent rollover instant on or before `now` — used as a staleness
|
||||
/// threshold for Wayfern fingerprints. Anything generated before this
|
||||
/// timestamp is considered stale and gets regenerated on next launch.
|
||||
fn most_recent_rollover_epoch() -> u64 {
|
||||
let now = Utc::now();
|
||||
let today_threshold = Utc
|
||||
.with_ymd_and_hms(
|
||||
now.year(),
|
||||
now.month(),
|
||||
now.day(),
|
||||
FINGERPRINT_ROLLOVER_HOUR_UTC,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.single()
|
||||
.unwrap_or(now);
|
||||
let threshold = if now >= today_threshold {
|
||||
today_threshold
|
||||
} else {
|
||||
today_threshold - chrono::Duration::days(1)
|
||||
};
|
||||
threshold.timestamp().max(0) as u64
|
||||
}
|
||||
|
||||
fn last_fp_refresh_path(profile_id: &str, profiles_dir: &std::path::Path) -> PathBuf {
|
||||
profiles_dir.join(profile_id).join(LAST_FP_REFRESH_FILE)
|
||||
}
|
||||
|
||||
/// Read the epoch-seconds timestamp stored in the per-profile refresh marker.
|
||||
/// Returns `None` if the file doesn't exist or its content can't be parsed —
|
||||
/// both signal "needs a refresh" to the caller.
|
||||
fn read_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path) -> Option<u64> {
|
||||
let path = last_fp_refresh_path(profile_id, profiles_dir);
|
||||
let content = std::fs::read_to_string(&path).ok()?;
|
||||
content.trim().parse::<u64>().ok()
|
||||
}
|
||||
|
||||
/// Record `ts` (epoch seconds) as the most recent fingerprint refresh for
|
||||
/// this profile. Failure is logged but never propagated — a missing marker
|
||||
/// only costs an extra regen on the next launch, never blocks one.
|
||||
fn write_last_fp_refresh(profile_id: &str, profiles_dir: &std::path::Path, ts: u64) {
|
||||
let path = last_fp_refresh_path(profile_id, profiles_dir);
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.exists() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
log::warn!("Failed to create profile dir for fingerprint refresh marker {profile_id}: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = std::fs::write(&path, ts.to_string()) {
|
||||
log::warn!("Failed to write fingerprint refresh marker for {profile_id}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BrowserRunner {
|
||||
pub profile_manager: &'static ProfileManager,
|
||||
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
|
||||
@@ -83,32 +151,73 @@ impl BrowserRunner {
|
||||
Ok(PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))
|
||||
}
|
||||
|
||||
async fn resolve_launch_hook_proxy(
|
||||
&self,
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<Option<ProxySettings>, String> {
|
||||
let Some(url) = profile.launch_hook.as_deref() else {
|
||||
return Ok(None);
|
||||
fn fire_launch_hook(profile: &BrowserProfile) {
|
||||
let Some(raw_url) = profile.launch_hook.as_deref() else {
|
||||
return;
|
||||
};
|
||||
let trimmed = raw_url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed = match url::Url::parse(trimmed) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Skipping launch hook for profile {} (ID: {}): invalid URL: {e}",
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"Calling launch hook for profile {} (ID: {})",
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
log::warn!(
|
||||
"Skipping launch hook for profile {} (ID: {}): URL must be http or https",
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
PROXY_MANAGER
|
||||
.fetch_proxy_from_url(url, Duration::from_millis(500))
|
||||
.await
|
||||
let url = parsed.to_string();
|
||||
let profile_name = profile.name.clone();
|
||||
let profile_id = profile.id.to_string();
|
||||
|
||||
log::info!("Firing launch hook GET {url} for profile {profile_name} (ID: {profile_id})");
|
||||
|
||||
tokio::spawn(async move {
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("Launch hook client build failed for {url}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match client.get(&url).send().await {
|
||||
Ok(resp) => {
|
||||
log::info!(
|
||||
"Launch hook {url} for profile {profile_name} returned status {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Launch hook {url} for profile {profile_name} failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn resolve_launch_proxy(
|
||||
&self,
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<Option<ProxySettings>, String> {
|
||||
if let Some(proxy_settings) = self.resolve_launch_hook_proxy(profile).await? {
|
||||
return Ok(Some(proxy_settings));
|
||||
}
|
||||
Self::fire_launch_hook(profile);
|
||||
|
||||
self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
@@ -291,8 +400,12 @@ impl BrowserRunner {
|
||||
);
|
||||
}
|
||||
|
||||
// Create ephemeral dir for ephemeral profiles
|
||||
let override_profile_path = if profile.ephemeral {
|
||||
// Create ephemeral dir for ephemeral or password-protected profiles
|
||||
let override_profile_path = if profile.password_protected {
|
||||
let dir = crate::profile::password::prepare_for_launch(profile)
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
Some(dir)
|
||||
} else if profile.ephemeral {
|
||||
let dir = crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string())
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
Some(dir)
|
||||
@@ -499,12 +612,32 @@ impl BrowserRunner {
|
||||
wayfern_config.proxy
|
||||
);
|
||||
|
||||
// Check if we need to generate a new fingerprint on every launch
|
||||
// Decide whether to (re)generate the Wayfern fingerprint for this
|
||||
// launch. Two triggers:
|
||||
//
|
||||
// 1. `randomize_fingerprint_on_launch = true` — explicit per-launch
|
||||
// randomization the user opted into.
|
||||
// 2. The fingerprint hasn't been refreshed since the most recent
|
||||
// rollover instant. We check the per-profile marker file first
|
||||
// (`.last-fp-refresh`); if it's absent we fall back to
|
||||
// `profile.created_at` so brand-new profiles don't immediately
|
||||
// regenerate the fingerprint they were just created with.
|
||||
// Profiles with neither (truly legacy) are treated as ancient
|
||||
// and refresh on next launch — once.
|
||||
let mut updated_profile = profile.clone();
|
||||
if wayfern_config.randomize_fingerprint_on_launch == Some(true) {
|
||||
let stale_threshold = most_recent_rollover_epoch();
|
||||
let profile_id_str = profile.id.to_string();
|
||||
let profiles_dir_for_marker = self.profile_manager.get_profiles_dir();
|
||||
let effective_last_refresh =
|
||||
read_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker).or(profile.created_at);
|
||||
let is_stale_profile = effective_last_refresh.is_none_or(|ts| ts < stale_threshold);
|
||||
let randomize_every_launch = wayfern_config.randomize_fingerprint_on_launch == Some(true);
|
||||
if randomize_every_launch || is_stale_profile {
|
||||
log::info!(
|
||||
"Generating random fingerprint for Wayfern profile: {}",
|
||||
profile.name
|
||||
"Generating Wayfern fingerprint for profile {} (per-launch={}, rollover={})",
|
||||
profile.name,
|
||||
randomize_every_launch,
|
||||
is_stale_profile
|
||||
);
|
||||
|
||||
// Create a config copy without the existing fingerprint to force generation of a new one
|
||||
@@ -526,10 +659,24 @@ impl BrowserRunner {
|
||||
// Update the config with the new fingerprint for launching
|
||||
wayfern_config.fingerprint = Some(new_fingerprint.clone());
|
||||
|
||||
// Save the updated fingerprint to the profile so it persists
|
||||
// Write the marker so the next launch within the same rollover
|
||||
// window skips this branch. The marker is excluded from cloud
|
||||
// sync (see `sync::manifest::DEFAULT_EXCLUDE_PATTERNS`), so each
|
||||
// device's refresh schedule is independent.
|
||||
let now_epoch = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(stale_threshold);
|
||||
write_last_fp_refresh(&profile_id_str, &profiles_dir_for_marker, now_epoch);
|
||||
|
||||
// Save the updated fingerprint to the profile so it persists.
|
||||
let mut updated_wayfern_config = updated_profile.wayfern_config.clone().unwrap_or_default();
|
||||
updated_wayfern_config.fingerprint = Some(new_fingerprint);
|
||||
updated_wayfern_config.randomize_fingerprint_on_launch = Some(true);
|
||||
// Preserve the user's randomize-on-launch preference rather than
|
||||
// forcing it on. The rollover path must not silently flip this
|
||||
// flag for users who only opted into the scheduled refresh.
|
||||
updated_wayfern_config.randomize_fingerprint_on_launch =
|
||||
wayfern_config.randomize_fingerprint_on_launch;
|
||||
if wayfern_config.os.is_some() {
|
||||
updated_wayfern_config.os = wayfern_config.os.clone();
|
||||
}
|
||||
@@ -542,8 +689,11 @@ impl BrowserRunner {
|
||||
);
|
||||
}
|
||||
|
||||
// Create ephemeral dir for ephemeral profiles
|
||||
if profile.ephemeral {
|
||||
// Create ephemeral dir for ephemeral or password-protected profiles
|
||||
if profile.password_protected {
|
||||
crate::profile::password::prepare_for_launch(profile)
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
} else if profile.ephemeral {
|
||||
crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string())
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
}
|
||||
@@ -1431,7 +1581,9 @@ impl BrowserRunner {
|
||||
);
|
||||
}
|
||||
|
||||
if profile.ephemeral {
|
||||
if profile.password_protected {
|
||||
crate::profile::password::complete_after_quit(profile);
|
||||
} else if profile.ephemeral {
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
|
||||
}
|
||||
|
||||
@@ -1771,7 +1923,9 @@ impl BrowserRunner {
|
||||
);
|
||||
}
|
||||
|
||||
if profile.ephemeral {
|
||||
if profile.password_protected {
|
||||
crate::profile::password::complete_after_quit(profile);
|
||||
} else if profile.ephemeral {
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::camoufox::env_vars;
|
||||
use crate::camoufox::fingerprint::types::*;
|
||||
use crate::camoufox::fonts;
|
||||
use crate::camoufox::geolocation;
|
||||
use crate::camoufox::presets;
|
||||
use crate::camoufox::webgl;
|
||||
|
||||
/// Browserforge mapping from YAML.
|
||||
@@ -307,10 +308,59 @@ impl CamoufoxConfigBuilder {
|
||||
}
|
||||
|
||||
/// Build the complete Camoufox launch configuration.
|
||||
///
|
||||
/// Prefers a real-fingerprint preset (matched against the Camoufox build's
|
||||
/// Firefox version via `presets::preset_line_for`) when no explicit
|
||||
/// fingerprint was passed. Falls back to the Bayesian network-based
|
||||
/// synthesizer when presets are unavailable, so callers without a known
|
||||
/// Firefox version (or with no preset for the requested OS) still get a
|
||||
/// valid config — matching pre-v150 behaviour byte-for-byte.
|
||||
pub fn build(self) -> Result<CamoufoxLaunchConfig, ConfigError> {
|
||||
// Generate or use provided fingerprint
|
||||
let fingerprint = if let Some(fp) = self.fingerprint {
|
||||
fp
|
||||
let mut rng = rand::rng();
|
||||
let ff_version = self.ff_version;
|
||||
|
||||
// 1) The caller supplied a fingerprint outright — honour it and skip
|
||||
// presets entirely. This is the path tests and advanced consumers
|
||||
// use to inject deterministic fixtures.
|
||||
// 2) Otherwise, try a bundled preset for the requested OS / FF line.
|
||||
// 3) Fall back to the Bayesian generator. This is also the path that
|
||||
// runs for users whose Camoufox binary has no readable `version.json`
|
||||
// (`ff_version == None`), or whose OS has no presets bundled.
|
||||
let (mut config, target_os) = if let Some(fp) = self.fingerprint {
|
||||
let target_os = env_vars::determine_ua_os(&fp.navigator.user_agent);
|
||||
// `from_browserforge` already runs `handle_screen_xy` internally.
|
||||
let config = from_browserforge(&fp, ff_version);
|
||||
(config, target_os)
|
||||
} else if let Some(preset) =
|
||||
presets::get_random_preset(self.operating_system.as_deref(), ff_version)
|
||||
{
|
||||
let mut config = presets::from_preset(&preset, ff_version);
|
||||
let target_os = config
|
||||
.get("navigator.userAgent")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(env_vars::determine_ua_os)
|
||||
.or_else(|| {
|
||||
// Last-resort heuristic from the platform string — keeps target_os
|
||||
// sensible even if a preset somehow omits the user agent.
|
||||
config
|
||||
.get("navigator.platform")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|p| match p {
|
||||
"Win32" => "windows",
|
||||
"MacIntel" => "macos",
|
||||
_ => "linux",
|
||||
})
|
||||
})
|
||||
.unwrap_or("macos");
|
||||
// Presets don't carry multi-monitor offsets, so default screenX/Y to
|
||||
// (0, 0) — matches what real single-display users send.
|
||||
config
|
||||
.entry("window.screenX".to_string())
|
||||
.or_insert(serde_json::json!(0));
|
||||
config
|
||||
.entry("window.screenY".to_string())
|
||||
.or_insert(serde_json::json!(0));
|
||||
(config, target_os)
|
||||
} else {
|
||||
let generator = crate::camoufox::fingerprint::FingerprintGenerator::new()?;
|
||||
let options = FingerprintOptions {
|
||||
@@ -320,17 +370,13 @@ impl CamoufoxConfigBuilder {
|
||||
screen: self.screen_constraints,
|
||||
..Default::default()
|
||||
};
|
||||
generator.get_fingerprint(&options)?.fingerprint
|
||||
let fingerprint = generator.get_fingerprint(&options)?.fingerprint;
|
||||
let target_os = env_vars::determine_ua_os(&fingerprint.navigator.user_agent);
|
||||
let config = from_browserforge(&fingerprint, ff_version);
|
||||
(config, target_os)
|
||||
};
|
||||
|
||||
// Determine target OS from user agent
|
||||
let target_os = env_vars::determine_ua_os(&fingerprint.navigator.user_agent);
|
||||
|
||||
// Convert fingerprint to config
|
||||
let mut config = from_browserforge(&fingerprint, self.ff_version);
|
||||
|
||||
// Add random window history length
|
||||
let mut rng = rand::rng();
|
||||
config.insert(
|
||||
"window.history.length".to_string(),
|
||||
serde_json::json!(rng.random_range(1..=5)),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,3 +7,21 @@ pub const FONTS_JSON: &str = include_str!("fonts.json");
|
||||
pub const BROWSERFORGE_YML: &str = include_str!("browserforge.yml");
|
||||
pub const WEBGL_DATA_DB: &[u8] = include_bytes!("webgl_data.db");
|
||||
pub const TERRITORY_INFO_XML: &str = include_str!("territoryInfo.xml");
|
||||
|
||||
/// Real fingerprint presets bundled with the original Camoufox v135 line
|
||||
/// (Firefox <= 148). Frozen upstream — kept around so users who haven't
|
||||
/// upgraded their Camoufox binary keep getting matched fingerprints.
|
||||
/// Mirrors `pythonlib/camoufox/fingerprint-presets.json` upstream.
|
||||
pub const FINGERPRINT_PRESETS_V135_JSON: &str = include_str!("fingerprint-presets-v135.json");
|
||||
|
||||
/// Real fingerprint presets for every Camoufox release after the v135 line
|
||||
/// (currently Firefox 149+ via the v150 build). This file is expected to
|
||||
/// be refreshed regularly as upstream Camoufox tracks newer Firefox
|
||||
/// releases — we keep the upstream filename here so each refresh is a
|
||||
/// straight `cp` from `pythonlib/camoufox/fingerprint-presets-v150.json`.
|
||||
pub const FINGERPRINT_PRESETS_NEWER_JSON: &str = include_str!("fingerprint-presets-v150.json");
|
||||
|
||||
/// Firefox major version at which the newer preset bundle takes over from
|
||||
/// the frozen v135 bundle. Matches `PRESETS_V150_MIN_FF` in
|
||||
/// `pythonlib/camoufox/fingerprints.py`.
|
||||
pub const PRESETS_NEWER_MIN_FF: u32 = 149;
|
||||
|
||||
@@ -43,6 +43,7 @@ pub mod fingerprint;
|
||||
pub mod fonts;
|
||||
pub mod geolocation;
|
||||
pub mod launcher;
|
||||
pub mod presets;
|
||||
pub mod webgl;
|
||||
|
||||
// Re-export main types for convenience
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
//! Real-fingerprint preset support for Camoufox.
|
||||
//!
|
||||
//! Mirrors the preset-selection logic from
|
||||
//! `pythonlib/camoufox/fingerprints.py` (`_select_presets_file`,
|
||||
//! `load_presets`, `get_random_preset`, `from_preset`).
|
||||
//!
|
||||
//! Camoufox ships two bundled preset files:
|
||||
//! - `fingerprint-presets-v135.json` — real fingerprints harvested from
|
||||
//! browsers running Firefox ≤148. The frozen "v135 line" — kept around
|
||||
//! so users who haven't upgraded their Camoufox binary keep getting
|
||||
//! consistent fingerprints.
|
||||
//! - `fingerprint-presets-v150.json` — the *newer* bundle, refreshed by
|
||||
//! upstream as Camoufox tracks newer Firefox versions. This is the
|
||||
//! bundle every newer Camoufox release uses; we make no assumption that
|
||||
//! Firefox 150 is the ceiling.
|
||||
//!
|
||||
//! At launch we know the bundled Firefox version (see
|
||||
//! `config::get_firefox_version`) and pick `v135` or `newer` accordingly.
|
||||
//! The split point lives in `data::PRESETS_NEWER_MIN_FF` (currently 149)
|
||||
//! and is the only number we hard-code — anything ≥ that gets the newer
|
||||
//! bundle, regardless of how far Firefox itself has moved on.
|
||||
//!
|
||||
//! Falling back to Bayesian-network synthesis (the previous default) is
|
||||
//! still possible when no preset matches the requested OS.
|
||||
|
||||
use rand::prelude::IndexedRandom;
|
||||
use regex_lite::Regex;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::camoufox::data;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Navigator {
|
||||
#[serde(rename = "userAgent")]
|
||||
pub user_agent: Option<String>,
|
||||
pub platform: Option<String>,
|
||||
#[serde(rename = "hardwareConcurrency")]
|
||||
pub hardware_concurrency: Option<u32>,
|
||||
#[serde(rename = "maxTouchPoints")]
|
||||
pub max_touch_points: Option<u32>,
|
||||
pub oscpu: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Screen {
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
#[serde(rename = "colorDepth")]
|
||||
pub color_depth: Option<u32>,
|
||||
#[serde(rename = "availWidth")]
|
||||
pub avail_width: Option<u32>,
|
||||
#[serde(rename = "availHeight")]
|
||||
pub avail_height: Option<u32>,
|
||||
#[serde(rename = "devicePixelRatio")]
|
||||
pub device_pixel_ratio: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct WebGl {
|
||||
#[serde(rename = "unmaskedVendor")]
|
||||
pub unmasked_vendor: Option<String>,
|
||||
#[serde(rename = "unmaskedRenderer")]
|
||||
pub unmasked_renderer: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Preset {
|
||||
#[serde(default)]
|
||||
pub navigator: Option<Navigator>,
|
||||
#[serde(default)]
|
||||
pub screen: Option<Screen>,
|
||||
#[serde(default)]
|
||||
pub webgl: Option<WebGl>,
|
||||
#[serde(default)]
|
||||
pub timezone: Option<String>,
|
||||
#[serde(default)]
|
||||
pub fonts: Option<Vec<String>>,
|
||||
#[serde(rename = "speechVoices", default)]
|
||||
pub speech_voices: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PresetBundle {
|
||||
/// Bundle schema version — upstream writes this as a JSON integer (e.g.
|
||||
/// `1`), so we accept any JSON shape here and ignore it. Only the
|
||||
/// `presets` map matters at runtime.
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
pub version: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub presets: HashMap<String, Vec<Preset>>,
|
||||
}
|
||||
|
||||
/// Which Camoufox release line the active binary belongs to. Determines
|
||||
/// which preset bundle to load. The set is intentionally just two-valued:
|
||||
/// the legacy v135 line and "everything newer" — upstream refreshes the
|
||||
/// newer bundle as Firefox versions advance, but our routing logic stays
|
||||
/// the same.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PresetLine {
|
||||
V135,
|
||||
Newer,
|
||||
}
|
||||
|
||||
/// Pick the preset line that matches a Firefox major version, mirroring
|
||||
/// `_select_presets_file` in the Python lib. Unknown / very old versions
|
||||
/// fall back to the v135 bundle so the older Camoufox builds keep working.
|
||||
pub fn preset_line_for(ff_version: Option<u32>) -> PresetLine {
|
||||
match ff_version {
|
||||
Some(v) if v >= data::PRESETS_NEWER_MIN_FF => PresetLine::Newer,
|
||||
_ => PresetLine::V135,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache the parsed bundles forever — they're static, embedded data and
|
||||
/// parsing the newer file twice would waste a few megs of CPU work on
|
||||
/// every launch.
|
||||
static V135_BUNDLE: OnceLock<Option<PresetBundle>> = OnceLock::new();
|
||||
static NEWER_BUNDLE: OnceLock<Option<PresetBundle>> = OnceLock::new();
|
||||
|
||||
fn parse_bundle(json: &str) -> Option<PresetBundle> {
|
||||
match serde_json::from_str::<PresetBundle>(json) {
|
||||
Ok(b) => Some(b),
|
||||
Err(e) => {
|
||||
log::warn!("camoufox preset bundle failed to parse: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_presets(line: PresetLine) -> Option<&'static PresetBundle> {
|
||||
let slot = match line {
|
||||
PresetLine::V135 => &V135_BUNDLE,
|
||||
PresetLine::Newer => &NEWER_BUNDLE,
|
||||
};
|
||||
slot
|
||||
.get_or_init(|| match line {
|
||||
PresetLine::V135 => parse_bundle(data::FINGERPRINT_PRESETS_V135_JSON),
|
||||
PresetLine::Newer => parse_bundle(data::FINGERPRINT_PRESETS_NEWER_JSON),
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// Normalize the OS string the rest of the codebase uses ("macos", "windows",
|
||||
/// "linux") to the preset key. Returns `None` for OSes that don't have any
|
||||
/// presets bundled.
|
||||
fn normalize_os(os: &str) -> Option<&'static str> {
|
||||
match os {
|
||||
"windows" | "win" => Some("windows"),
|
||||
"macos" | "mac" | "darwin" => Some("macos"),
|
||||
"linux" | "lin" => Some("linux"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick a random preset for the requested OS. `None` if there are no
|
||||
/// presets bundled for that OS (which can happen in tests with reduced
|
||||
/// fixtures, or if a new OS is added before its preset bundle ships).
|
||||
pub fn get_random_preset(os: Option<&str>, ff_version: Option<u32>) -> Option<Preset> {
|
||||
let bundle = load_presets(preset_line_for(ff_version))?;
|
||||
|
||||
let candidates: Vec<&Preset> = match os.and_then(normalize_os) {
|
||||
Some(os_key) => bundle.presets.get(os_key).map(|v| v.iter().collect())?,
|
||||
None => bundle.presets.values().flatten().collect(),
|
||||
};
|
||||
if candidates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
candidates.choose(&mut rand::rng()).map(|p| (*p).clone())
|
||||
}
|
||||
|
||||
/// Match python's `from_preset` — translate a real-fingerprint preset into
|
||||
/// the CAMOU_CONFIG-style HashMap the rest of the launcher expects.
|
||||
///
|
||||
/// The caller is responsible for filling in fonts, voices, and the random
|
||||
/// seeds; those are intentionally left out here so each call site can layer
|
||||
/// its own RNG and font policy.
|
||||
pub fn from_preset(preset: &Preset, ff_version: Option<u32>) -> HashMap<String, serde_json::Value> {
|
||||
let mut config: HashMap<String, serde_json::Value> = HashMap::new();
|
||||
|
||||
if let Some(nav) = &preset.navigator {
|
||||
if let Some(ua) = &nav.user_agent {
|
||||
let ua = if let Some(v) = ff_version {
|
||||
rewrite_ua_firefox_version(ua, v)
|
||||
} else {
|
||||
ua.clone()
|
||||
};
|
||||
config.insert("navigator.userAgent".to_string(), serde_json::json!(ua));
|
||||
}
|
||||
if let Some(p) = &nav.platform {
|
||||
config.insert("navigator.platform".to_string(), serde_json::json!(p));
|
||||
}
|
||||
if let Some(hc) = nav.hardware_concurrency {
|
||||
config.insert(
|
||||
"navigator.hardwareConcurrency".to_string(),
|
||||
serde_json::json!(hc),
|
||||
);
|
||||
}
|
||||
if let Some(mtp) = nav.max_touch_points {
|
||||
config.insert(
|
||||
"navigator.maxTouchPoints".to_string(),
|
||||
serde_json::json!(mtp),
|
||||
);
|
||||
}
|
||||
// navigator.oscpu — explicit, or derived from the platform.
|
||||
let oscpu = nav.oscpu.clone().or_else(|| {
|
||||
nav.platform.as_deref().and_then(|plat| match plat {
|
||||
"MacIntel" => Some("Intel Mac OS X 10.15".to_string()),
|
||||
"Win32" => Some("Windows NT 10.0; Win64; x64".to_string()),
|
||||
p if p.to_ascii_lowercase().contains("linux") => Some("Linux x86_64".to_string()),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if let Some(o) = oscpu {
|
||||
config.insert("navigator.oscpu".to_string(), serde_json::json!(o));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(s) = &preset.screen {
|
||||
if let Some(w) = s.width {
|
||||
config.insert("screen.width".to_string(), serde_json::json!(w));
|
||||
}
|
||||
if let Some(h) = s.height {
|
||||
config.insert("screen.height".to_string(), serde_json::json!(h));
|
||||
}
|
||||
if let Some(cd) = s.color_depth {
|
||||
config.insert("screen.colorDepth".to_string(), serde_json::json!(cd));
|
||||
config.insert("screen.pixelDepth".to_string(), serde_json::json!(cd));
|
||||
}
|
||||
if let Some(aw) = s.avail_width {
|
||||
config.insert("screen.availWidth".to_string(), serde_json::json!(aw));
|
||||
}
|
||||
if let Some(ah) = s.avail_height {
|
||||
config.insert("screen.availHeight".to_string(), serde_json::json!(ah));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(w) = &preset.webgl {
|
||||
if let Some(v) = &w.unmasked_vendor {
|
||||
config.insert("webGl:vendor".to_string(), serde_json::json!(v));
|
||||
}
|
||||
if let Some(r) = &w.unmasked_renderer {
|
||||
config.insert("webGl:renderer".to_string(), serde_json::json!(r));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tz) = &preset.timezone {
|
||||
config.insert("timezone".to_string(), serde_json::json!(tz));
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
fn rewrite_ua_firefox_version(ua: &str, version: u32) -> String {
|
||||
let firefox_re = Regex::new(r"Firefox/\d+\.0").expect("static regex");
|
||||
let rv_re = Regex::new(r"rv:\d+\.0").expect("static regex");
|
||||
let first = firefox_re.replace_all(ua, format!("Firefox/{version}.0"));
|
||||
rv_re
|
||||
.replace_all(&first, format!("rv:{version}.0"))
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn picks_v135_for_old_firefox() {
|
||||
assert_eq!(preset_line_for(Some(135)), PresetLine::V135);
|
||||
assert_eq!(preset_line_for(Some(148)), PresetLine::V135);
|
||||
assert_eq!(preset_line_for(None), PresetLine::V135);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_newer_for_anything_past_the_legacy_line() {
|
||||
// The threshold is data::PRESETS_NEWER_MIN_FF (currently 149).
|
||||
// Future Firefox versions all share the same bundle — there's
|
||||
// intentionally no per-version routing past v135.
|
||||
assert_eq!(preset_line_for(Some(149)), PresetLine::Newer);
|
||||
assert_eq!(preset_line_for(Some(150)), PresetLine::Newer);
|
||||
assert_eq!(preset_line_for(Some(160)), PresetLine::Newer);
|
||||
assert_eq!(preset_line_for(Some(200)), PresetLine::Newer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_bundles_parse_and_cover_all_platforms() {
|
||||
for (line, json) in [
|
||||
(PresetLine::V135, data::FINGERPRINT_PRESETS_V135_JSON),
|
||||
(PresetLine::Newer, data::FINGERPRINT_PRESETS_NEWER_JSON),
|
||||
] {
|
||||
let bundle: PresetBundle =
|
||||
serde_json::from_str(json).unwrap_or_else(|e| panic!("bundle {line:?} parse error: {e}"));
|
||||
for os in ["macos", "windows", "linux"] {
|
||||
let presets = bundle.presets.get(os).unwrap_or_else(|| {
|
||||
panic!("bundle {line:?} is missing presets for {os}");
|
||||
});
|
||||
assert!(
|
||||
!presets.is_empty(),
|
||||
"bundle {line:?} has zero presets for {os}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_preset_returns_for_each_os() {
|
||||
for os in ["macos", "windows", "linux"] {
|
||||
let preset = get_random_preset(Some(os), Some(150)).expect("preset");
|
||||
assert!(preset.navigator.is_some(), "navigator present for {os}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_preset_rewrites_firefox_version() {
|
||||
let preset = Preset {
|
||||
navigator: Some(Navigator {
|
||||
user_agent: Some(
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0".to_string(),
|
||||
),
|
||||
platform: Some("Linux x86_64".to_string()),
|
||||
hardware_concurrency: Some(8),
|
||||
max_touch_points: Some(0),
|
||||
oscpu: None,
|
||||
}),
|
||||
screen: None,
|
||||
webgl: None,
|
||||
timezone: None,
|
||||
fonts: None,
|
||||
speech_voices: None,
|
||||
};
|
||||
let config = from_preset(&preset, Some(150));
|
||||
let ua = config
|
||||
.get("navigator.userAgent")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap();
|
||||
assert!(ua.contains("Firefox/150.0"), "got: {ua}");
|
||||
assert!(ua.contains("rv:150.0"), "got: {ua}");
|
||||
// oscpu derived from "Linux x86_64" platform
|
||||
assert_eq!(
|
||||
config
|
||||
.get("navigator.oscpu")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap(),
|
||||
"Linux x86_64"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_preset_derives_oscpu_for_mac_and_win() {
|
||||
let mut preset = Preset {
|
||||
navigator: Some(Navigator {
|
||||
user_agent: None,
|
||||
platform: Some("MacIntel".to_string()),
|
||||
hardware_concurrency: None,
|
||||
max_touch_points: None,
|
||||
oscpu: None,
|
||||
}),
|
||||
screen: None,
|
||||
webgl: None,
|
||||
timezone: None,
|
||||
fonts: None,
|
||||
speech_voices: None,
|
||||
};
|
||||
assert_eq!(
|
||||
from_preset(&preset, None)
|
||||
.get("navigator.oscpu")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap(),
|
||||
"Intel Mac OS X 10.15"
|
||||
);
|
||||
preset.navigator.as_mut().unwrap().platform = Some("Win32".to_string());
|
||||
assert_eq!(
|
||||
from_preset(&preset, None)
|
||||
.get("navigator.oscpu")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap(),
|
||||
"Windows NT 10.0; Win64; x64"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_color_depth_fills_both_keys() {
|
||||
let preset = Preset {
|
||||
navigator: None,
|
||||
screen: Some(Screen {
|
||||
width: Some(1920),
|
||||
height: Some(1080),
|
||||
color_depth: Some(24),
|
||||
avail_width: Some(1920),
|
||||
avail_height: Some(1050),
|
||||
device_pixel_ratio: Some(1.0),
|
||||
}),
|
||||
webgl: None,
|
||||
timezone: None,
|
||||
fonts: None,
|
||||
speech_voices: None,
|
||||
};
|
||||
let config = from_preset(&preset, None);
|
||||
assert_eq!(config.get("screen.colorDepth").unwrap(), 24);
|
||||
assert_eq!(config.get("screen.pixelDepth").unwrap(), 24);
|
||||
assert_eq!(config.get("screen.availWidth").unwrap(), 1920);
|
||||
}
|
||||
}
|
||||
@@ -127,8 +127,16 @@ lazy_static! {
|
||||
impl CloudAuthManager {
|
||||
fn new() -> Self {
|
||||
let state = Self::load_auth_state_from_disk();
|
||||
// Bound every cloud API call so no single slow / hung request can stall
|
||||
// the startup chain (sync-token → proxy-config → wayfern-token), which
|
||||
// otherwise gates Wayfern launch behind whichever endpoint is slowest.
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.connect_timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
Self {
|
||||
client: Client::new(),
|
||||
client,
|
||||
state: Mutex::new(state),
|
||||
refresh_lock: tokio::sync::Mutex::new(()),
|
||||
wayfern_token: Mutex::new(None),
|
||||
@@ -990,7 +998,15 @@ impl CloudAuthManager {
|
||||
let token = self
|
||||
.api_call_with_retry(|access_token| {
|
||||
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
|
||||
let client = reqwest::Client::new();
|
||||
// Bound the request: without a timeout, an unreachable
|
||||
// api.donutbrowser.com hangs the background fetch indefinitely,
|
||||
// which in turn forces wayfern_manager's launch-time wait to
|
||||
// exhaust its full polling budget every time.
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(8))
|
||||
.connect_timeout(std::time::Duration::from_secs(4))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
async move {
|
||||
let response = client
|
||||
.post(&url)
|
||||
@@ -1199,13 +1215,14 @@ pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
|
||||
pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
CLOUD_AUTH.logout().await?;
|
||||
|
||||
// Clear sync settings if they point to the cloud URL (prevent leak into Self-Hosted tab)
|
||||
// Always clear the stored sync URL and token on cloud logout. While the
|
||||
// user was signed in, the cloud auth flow populated these with the hosted
|
||||
// sync server's URL + a server-issued token — leaving them in place would
|
||||
// pre-fill the Self-Hosted tab with our production URL and a token the
|
||||
// user never typed. The cloud-URL-only check we used to do here missed
|
||||
// trailing-slash / scheme variants and any future cloud endpoint moves.
|
||||
let manager = crate::settings_manager::SettingsManager::instance();
|
||||
if let Ok(sync_settings) = manager.get_sync_settings() {
|
||||
if sync_settings.sync_server_url.as_deref() == Some(CLOUD_SYNC_URL) {
|
||||
let _ = manager.save_sync_server_url(None);
|
||||
}
|
||||
}
|
||||
let _ = manager.save_sync_server_url(None);
|
||||
let _ = manager.remove_sync_token(&app_handle).await;
|
||||
|
||||
// Remove cloud-managed and cloud-derived proxies
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::profile::manager::ProfileManager;
|
||||
use crate::profile::BrowserProfile;
|
||||
use rusqlite::{params, Connection};
|
||||
use rusqlite::{params, Connection, OpenFlags};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
@@ -134,6 +134,24 @@ pub struct CookieReadResult {
|
||||
pub total_count: usize,
|
||||
}
|
||||
|
||||
/// Lightweight cookie metadata for the profile-info dialog. Computed without
|
||||
/// decrypting any cookie values, so it stays cheap even for multi-MB Chromium
|
||||
/// cookie stores and never blocks the runtime for noticeable time.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CookieStats {
|
||||
pub profile_id: String,
|
||||
pub browser_type: String,
|
||||
pub total_count: usize,
|
||||
/// Every domain the profile has cookies for, sorted by cookie count desc.
|
||||
pub domains: Vec<DomainCount>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DomainCount {
|
||||
pub domain: String,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
/// Request to copy specific cookies
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CookieCopyRequest {
|
||||
@@ -694,6 +712,135 @@ impl CookieManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Open the cookie SQLite database read-only without acquiring any lock.
|
||||
///
|
||||
/// `immutable=1` tells SQLite the file will not change during the read,
|
||||
/// which causes it to skip all locking. That lets us read metadata even
|
||||
/// while the browser holds an exclusive lock on the cookies database —
|
||||
/// the trade-off is that we may see a slightly stale snapshot, which is
|
||||
/// acceptable for the badge/preview use cases this powers.
|
||||
fn open_cookie_db_readonly(db_path: &Path) -> Result<Connection, String> {
|
||||
let path_str = db_path.to_string_lossy();
|
||||
if path_str.contains('?') || path_str.contains('#') {
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "COOKIE_DB_UNAVAILABLE",
|
||||
"params": { "detail": "profile path contains a reserved URI character" }
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let uri = format!("file:{path_str}?mode=ro&immutable=1");
|
||||
Connection::open_with_flags(
|
||||
&uri,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY
|
||||
| OpenFlags::SQLITE_OPEN_URI
|
||||
| OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| {
|
||||
let code = if e.to_string().to_lowercase().contains("locked") {
|
||||
"COOKIE_DB_LOCKED"
|
||||
} else {
|
||||
"COOKIE_DB_UNAVAILABLE"
|
||||
};
|
||||
serde_json::json!({
|
||||
"code": code,
|
||||
"params": { "detail": e.to_string() }
|
||||
})
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// Public API: read lightweight stats (total count + top 5 domains) for a
|
||||
/// profile's cookie store. Reads from a snapshot view of the SQLite file
|
||||
/// without holding a lock, so this works while the browser is running.
|
||||
pub fn read_stats(profile_id: &str) -> Result<CookieStats, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles_dir = profile_manager.get_profiles_dir();
|
||||
let profiles = profile_manager.list_profiles().map_err(|e| {
|
||||
serde_json::json!({
|
||||
"code": "COOKIE_DB_UNAVAILABLE",
|
||||
"params": { "detail": e.to_string() }
|
||||
})
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
let profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?;
|
||||
|
||||
let db_path = Self::get_cookie_db_path(profile, &profiles_dir).map_err(|e| {
|
||||
serde_json::json!({
|
||||
"code": "COOKIE_DB_UNAVAILABLE",
|
||||
"params": { "detail": e }
|
||||
})
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
let conn = Self::open_cookie_db_readonly(&db_path)?;
|
||||
|
||||
let (count_sql, domain_sql) = match profile.browser.as_str() {
|
||||
"camoufox" => (
|
||||
"SELECT COUNT(*) FROM moz_cookies",
|
||||
"SELECT host, COUNT(*) FROM moz_cookies GROUP BY host ORDER BY COUNT(*) DESC, host ASC",
|
||||
),
|
||||
"wayfern" => (
|
||||
"SELECT COUNT(*) FROM cookies",
|
||||
"SELECT host_key, COUNT(*) FROM cookies GROUP BY host_key ORDER BY COUNT(*) DESC, host_key ASC",
|
||||
),
|
||||
_ => {
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "COOKIE_DB_UNAVAILABLE",
|
||||
"params": { "detail": format!("unsupported browser: {}", profile.browser) }
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let total_count: usize = conn
|
||||
.query_row(count_sql, [], |row| row.get::<_, i64>(0))
|
||||
.map_err(|e| {
|
||||
serde_json::json!({
|
||||
"code": "COOKIE_DB_UNAVAILABLE",
|
||||
"params": { "detail": e.to_string() }
|
||||
})
|
||||
.to_string()
|
||||
})? as usize;
|
||||
|
||||
let mut stmt = conn.prepare(domain_sql).map_err(|e| {
|
||||
serde_json::json!({
|
||||
"code": "COOKIE_DB_UNAVAILABLE",
|
||||
"params": { "detail": e.to_string() }
|
||||
})
|
||||
.to_string()
|
||||
})?;
|
||||
let domains: Vec<DomainCount> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(DomainCount {
|
||||
domain: row.get::<_, String>(0)?,
|
||||
count: row.get::<_, i64>(1)? as usize,
|
||||
})
|
||||
})
|
||||
.and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
|
||||
.map_err(|e| {
|
||||
serde_json::json!({
|
||||
"code": "COOKIE_DB_UNAVAILABLE",
|
||||
"params": { "detail": e.to_string() }
|
||||
})
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
Ok(CookieStats {
|
||||
profile_id: profile_id.to_string(),
|
||||
browser_type: profile.browser.clone(),
|
||||
total_count,
|
||||
domains,
|
||||
})
|
||||
}
|
||||
|
||||
/// Public API: Copy cookies between profiles
|
||||
pub async fn copy_cookies(
|
||||
app_handle: &AppHandle,
|
||||
|
||||
@@ -240,7 +240,7 @@ fn cleanup_legacy_dirs() {
|
||||
}
|
||||
|
||||
pub fn get_effective_profile_path(profile: &BrowserProfile, profiles_dir: &Path) -> PathBuf {
|
||||
if profile.ephemeral {
|
||||
if profile.ephemeral || profile.password_protected {
|
||||
if let Some(dir) = get_ephemeral_dir(&profile.id.to_string()) {
|
||||
return dir;
|
||||
}
|
||||
@@ -279,6 +279,8 @@ mod tests {
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+70
-23
@@ -12,6 +12,39 @@ use tokio::process::Command;
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::fs::create_dir_all;
|
||||
|
||||
/// Returns true if `path` carries a `com.apple.quarantine` extended attribute.
|
||||
///
|
||||
/// Uses `getxattr` with a null buffer to query the attribute size only —
|
||||
/// this is a read-only syscall and does NOT trigger macOS Sequoia's App
|
||||
/// Management TCC prompt. We use it to gate the `xattr -d` removal: macOS
|
||||
/// fires the prompt on the modify-class syscall (`removexattr`) even when
|
||||
/// the operation is a no-op, so skipping the call entirely when the
|
||||
/// attribute is absent is the only way to stay quiet.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn has_quarantine_attr(path: &Path) -> bool {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let Ok(path_c) = CString::new(path.as_os_str().as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(attr_c) = CString::new("com.apple.quarantine") else {
|
||||
return false;
|
||||
};
|
||||
// SAFETY: getxattr is a stable libc API. Passing a null buffer with size 0
|
||||
// makes it a pure read-only size query.
|
||||
let result = unsafe {
|
||||
libc::getxattr(
|
||||
path_c.as_ptr(),
|
||||
attr_c.as_ptr(),
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
};
|
||||
result >= 0
|
||||
}
|
||||
|
||||
pub struct Extractor;
|
||||
|
||||
impl Extractor {
|
||||
@@ -207,18 +240,23 @@ impl Extractor {
|
||||
|
||||
match extraction_result {
|
||||
Ok(path) => {
|
||||
// Remove quarantine attributes on macOS to prevent
|
||||
// "app was prevented from modifying data" prompts
|
||||
// Remove quarantine attributes on macOS to prevent Gatekeeper prompts —
|
||||
// but only if there's actually something to remove. Calling the
|
||||
// modify-class `removexattr` syscall on a file without quarantine still
|
||||
// fires macOS Sequoia's App Management TCC notification, so we skip
|
||||
// the call entirely when the attribute is absent.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = tokio::process::Command::new("xattr")
|
||||
.args([
|
||||
"-dr",
|
||||
"com.apple.quarantine",
|
||||
dest_dir.to_str().unwrap_or("."),
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
if has_quarantine_attr(dest_dir) {
|
||||
let _ = tokio::process::Command::new("xattr")
|
||||
.args([
|
||||
"-dr",
|
||||
"com.apple.quarantine",
|
||||
dest_dir.to_str().unwrap_or("."),
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
@@ -419,9 +457,15 @@ impl Extractor {
|
||||
|
||||
log::info!("Copying .app to: {}", app_path.display());
|
||||
|
||||
// `-X` strips extended attributes (notably com.apple.quarantine) during
|
||||
// the copy itself. Without it, `cp -R` preserves quarantine from the
|
||||
// mounted DMG, which then has to be removed with `xattr -dr` — and that
|
||||
// removexattr syscall on a signed .app bundle trips macOS Sequoia's App
|
||||
// Management TCC notification ("Donut.app was prevented from modifying
|
||||
// apps on your Mac"). Stripping at copy time is silent.
|
||||
let output = Command::new("cp")
|
||||
.args([
|
||||
"-R",
|
||||
"-RX",
|
||||
app_entry.to_str().unwrap(),
|
||||
app_path.to_str().unwrap(),
|
||||
])
|
||||
@@ -444,18 +488,21 @@ impl Extractor {
|
||||
|
||||
log::info!("Successfully copied .app bundle");
|
||||
|
||||
// Remove quarantine attributes
|
||||
let _ = Command::new("xattr")
|
||||
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let _ = Command::new("xattr")
|
||||
.args(["-cr", app_path.to_str().unwrap()])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
log::info!("Removed quarantine attributes");
|
||||
// Remove the macOS quarantine attribute so Gatekeeper doesn't block launch
|
||||
// — but only if it's actually present. A no-op `removexattr` syscall on a
|
||||
// signed .app bundle still trips macOS Sequoia's App Management privacy
|
||||
// prompt ("Donut.app was prevented from modifying apps on your Mac"),
|
||||
// even when no modification actually happens, so we gate the call behind
|
||||
// a read-only `getxattr` check.
|
||||
if has_quarantine_attr(&app_path) {
|
||||
let _ = Command::new("xattr")
|
||||
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
|
||||
.output()
|
||||
.await;
|
||||
log::info!("Removed quarantine attributes");
|
||||
} else {
|
||||
log::info!("No quarantine attribute on .app, skipping xattr removal");
|
||||
}
|
||||
|
||||
// Unmount the DMG
|
||||
let output = Command::new("hdiutil")
|
||||
|
||||
+132
-33
@@ -72,6 +72,11 @@ use profile::manager::{
|
||||
update_wayfern_config,
|
||||
};
|
||||
|
||||
use profile::password::{
|
||||
change_profile_password, is_profile_locked, lock_profile, remove_profile_password,
|
||||
set_profile_password, unlock_profile,
|
||||
};
|
||||
|
||||
use browser_version_manager::{
|
||||
fetch_browser_versions_cached_first, fetch_browser_versions_with_count,
|
||||
fetch_browser_versions_with_count_cached_first, get_supported_browsers,
|
||||
@@ -88,16 +93,16 @@ use downloader::{cancel_download, download_browser};
|
||||
use settings_manager::{
|
||||
decline_launch_on_login, dismiss_window_resize_warning, enable_launch_on_login, get_app_settings,
|
||||
get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings,
|
||||
get_window_resize_warning_dismissed, save_app_settings, save_sync_settings,
|
||||
save_table_sorting_settings, should_show_launch_on_login_prompt,
|
||||
get_window_resize_warning_dismissed, open_log_directory, read_log_files, save_app_settings,
|
||||
save_sync_settings, save_table_sorting_settings, should_show_launch_on_login_prompt,
|
||||
};
|
||||
|
||||
use sync::{
|
||||
check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities,
|
||||
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile,
|
||||
is_vpn_in_use_by_synced_profile, request_profile_sync, set_e2e_password,
|
||||
set_extension_group_sync_enabled, set_extension_sync_enabled, set_group_sync_enabled,
|
||||
set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled,
|
||||
is_vpn_in_use_by_synced_profile, request_profile_sync, rollover_encryption_for_all_entities,
|
||||
set_e2e_password, set_extension_group_sync_enabled, set_extension_sync_enabled,
|
||||
set_group_sync_enabled, set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled,
|
||||
};
|
||||
|
||||
use tag_manager::get_all_tags;
|
||||
@@ -305,8 +310,21 @@ async fn import_proxies_from_parsed(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_profile_cookies(profile_id: String) -> Result<cookie_manager::CookieReadResult, String> {
|
||||
cookie_manager::CookieManager::read_cookies(&profile_id)
|
||||
async fn read_profile_cookies(
|
||||
profile_id: String,
|
||||
) -> Result<cookie_manager::CookieReadResult, String> {
|
||||
tokio::task::spawn_blocking(move || cookie_manager::CookieManager::read_cookies(&profile_id))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read profile cookies: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_profile_cookie_stats(
|
||||
profile_id: String,
|
||||
) -> Result<cookie_manager::CookieStats, String> {
|
||||
tokio::task::spawn_blocking(move || cookie_manager::CookieManager::read_stats(&profile_id))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read profile cookie stats: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -675,11 +693,17 @@ fn find_claude_cli() -> Option<std::path::PathBuf> {
|
||||
}
|
||||
|
||||
#[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 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"])
|
||||
.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}"))?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout.contains("donut-browser"))
|
||||
@@ -742,6 +766,15 @@ async fn get_all_traffic_snapshots() -> Result<Vec<crate::traffic_stats::Traffic
|
||||
Ok(crate::traffic_stats::get_all_traffic_snapshots_realtime())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_profile_traffic_snapshot(
|
||||
profile_id: String,
|
||||
) -> Result<Option<crate::traffic_stats::TrafficSnapshot>, String> {
|
||||
Ok(crate::traffic_stats::get_traffic_snapshot_for_profile(
|
||||
&profile_id,
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn clear_all_traffic_stats() -> Result<(), String> {
|
||||
crate::traffic_stats::clear_all_traffic_stats()
|
||||
@@ -1127,6 +1160,8 @@ async fn generate_sample_fingerprint(
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
};
|
||||
|
||||
if browser == "camoufox" {
|
||||
@@ -1174,7 +1209,11 @@ pub fn run() {
|
||||
.target(Target::new(TargetKind::LogDir {
|
||||
file_name: Some(log_file_name.to_string()),
|
||||
}))
|
||||
.max_file_size(100_000) // 100KB
|
||||
// 5 MB per rotated file × KeepAll — the previous 100 KB limit
|
||||
// truncated useful context in customer support reports; 50 MB
|
||||
// turned out to be excessive disk pressure.
|
||||
.max_file_size(5 * 1024 * 1024)
|
||||
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
|
||||
.level(log::LevelFilter::Info)
|
||||
.format(|out, message, record| {
|
||||
use chrono::Local;
|
||||
@@ -1210,6 +1249,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_macos_permissions::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.setup(|app| {
|
||||
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
|
||||
ephemeral_dirs::recover_ephemeral_dirs();
|
||||
@@ -1232,7 +1272,7 @@ pub fn run() {
|
||||
#[allow(unused_variables)]
|
||||
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
||||
.title("Donut Browser")
|
||||
.inner_size(800.0, 500.0)
|
||||
.inner_size(880.0, 500.0)
|
||||
.resizable(false)
|
||||
.fullscreen(false)
|
||||
.center()
|
||||
@@ -1338,18 +1378,31 @@ pub fn run() {
|
||||
version_updater::VersionUpdater::run_background_task().await;
|
||||
});
|
||||
|
||||
// Auto-start MCP server if it was previously enabled
|
||||
// Auto-start MCP server if it was previously enabled. Always log the
|
||||
// decision so customer logs reveal whether MCP is actually running —
|
||||
// "automation features don't work" is otherwise indistinguishable from
|
||||
// "MCP server isn't enabled" without this line.
|
||||
{
|
||||
let mcp_handle = app.handle().clone();
|
||||
let settings_mgr = settings_manager::SettingsManager::instance();
|
||||
if let Ok(settings) = settings_mgr.load_settings() {
|
||||
if settings.mcp_enabled {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match mcp_server::McpServer::instance().start(mcp_handle).await {
|
||||
Ok(port) => log::info!("MCP server auto-started on port {port}"),
|
||||
Err(e) => log::warn!("Failed to auto-start MCP server: {e}"),
|
||||
}
|
||||
});
|
||||
match settings_mgr.load_settings() {
|
||||
Ok(settings) => {
|
||||
if settings.mcp_enabled {
|
||||
log::info!("MCP server is enabled in settings, attempting auto-start");
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match mcp_server::McpServer::instance().start(mcp_handle).await {
|
||||
Ok(port) => log::info!("MCP server auto-started on port {port}"),
|
||||
Err(e) => log::warn!("Failed to auto-start MCP server: {e}"),
|
||||
}
|
||||
});
|
||||
} else {
|
||||
log::info!(
|
||||
"MCP server is DISABLED in settings (mcp_enabled=false). Browser automation tools will not be available until it's enabled in Settings → Integrations."
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Could not read settings to determine MCP state: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1710,7 +1763,23 @@ pub fn run() {
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
}
|
||||
|
||||
for profile in profiles {
|
||||
// Only walk profiles that either have a stored PID or that we last
|
||||
// saw as running — for users with hundreds of idle profiles this
|
||||
// turns an O(N) sysinfo scan into an O(running) scan. The Rust
|
||||
// launch path always emits profile-running-changed when a profile
|
||||
// STARTS, so newly-running profiles still get tracked here.
|
||||
let profiles_to_check: Vec<_> = profiles
|
||||
.into_iter()
|
||||
.filter(|p| {
|
||||
p.process_id.is_some()
|
||||
|| last_running_states
|
||||
.get(&p.id.to_string())
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for profile in profiles_to_check {
|
||||
// Check browser status and track changes
|
||||
match runner
|
||||
.check_browser_status(app_handle_status.clone(), &profile)
|
||||
@@ -1763,6 +1832,13 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-encrypt password-protected profiles when the browser
|
||||
// exits naturally (user closing the window) — the explicit
|
||||
// kill path in browser_runner.rs handles app-driven stops.
|
||||
if !is_running && profile.password_protected {
|
||||
crate::profile::password::complete_after_quit(&profile);
|
||||
}
|
||||
|
||||
last_running_states.insert(profile_id, is_running);
|
||||
} else {
|
||||
// Update the state even if unchanged to ensure we have it tracked
|
||||
@@ -1882,21 +1958,31 @@ pub fn run() {
|
||||
// Start cloud auth background refresh loop
|
||||
let app_handle_cloud = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// On startup, refresh sync token and proxy if cloud auth is active.
|
||||
// On startup, refresh sync token, proxy config, and wayfern token in
|
||||
// PARALLEL. Previously they were awaited sequentially, so the wayfern
|
||||
// token request didn't even start until the earlier two API calls had
|
||||
// finished. Wayfern launch can race with this task — a few seconds of
|
||||
// serialized API calls translates directly into a slow first launch
|
||||
// because launch_wayfern blocks waiting for the token to land.
|
||||
// api_call_with_retry handles 401/refresh internally — no direct
|
||||
// refresh_access_token call needed.
|
||||
if cloud_auth::CLOUD_AUTH.is_logged_in().await {
|
||||
if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await {
|
||||
log::warn!("Failed to refresh cloud sync token on startup: {e}");
|
||||
}
|
||||
cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
|
||||
// Request wayfern token on startup for paid users
|
||||
if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
|
||||
log::warn!("Failed to request wayfern token on startup: {e}");
|
||||
let sync_token_fut = async {
|
||||
if let Err(e) = cloud_auth::CLOUD_AUTH.get_or_refresh_sync_token().await {
|
||||
log::warn!("Failed to refresh cloud sync token on startup: {e}");
|
||||
}
|
||||
}
|
||||
};
|
||||
let proxy_fut = async {
|
||||
cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
};
|
||||
let wayfern_fut = async {
|
||||
if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
|
||||
log::warn!("Failed to request wayfern token on startup: {e}");
|
||||
}
|
||||
}
|
||||
};
|
||||
tokio::join!(sync_token_fut, proxy_fut, wayfern_fut);
|
||||
}
|
||||
cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await;
|
||||
});
|
||||
@@ -1932,6 +2018,8 @@ pub fn run() {
|
||||
rename_profile,
|
||||
get_app_settings,
|
||||
save_app_settings,
|
||||
read_log_files,
|
||||
open_log_directory,
|
||||
should_show_launch_on_login_prompt,
|
||||
enable_launch_on_login,
|
||||
decline_launch_on_login,
|
||||
@@ -1999,6 +2087,7 @@ pub fn run() {
|
||||
stop_api_server,
|
||||
get_api_server_status,
|
||||
get_all_traffic_snapshots,
|
||||
get_profile_traffic_snapshot,
|
||||
clear_all_traffic_stats,
|
||||
get_traffic_stats_for_period,
|
||||
get_sync_settings,
|
||||
@@ -2018,7 +2107,9 @@ pub fn run() {
|
||||
set_e2e_password,
|
||||
check_has_e2e_password,
|
||||
delete_e2e_password,
|
||||
rollover_encryption_for_all_entities,
|
||||
read_profile_cookies,
|
||||
get_profile_cookie_stats,
|
||||
copy_profile_cookies,
|
||||
import_cookies_from_file,
|
||||
export_profile_cookies,
|
||||
@@ -2076,6 +2167,13 @@ pub fn run() {
|
||||
// DNS blocklist commands
|
||||
dns_blocklist::get_dns_blocklist_cache_status,
|
||||
dns_blocklist::refresh_dns_blocklists,
|
||||
// Profile password commands
|
||||
set_profile_password,
|
||||
change_profile_password,
|
||||
remove_profile_password,
|
||||
unlock_profile,
|
||||
lock_profile,
|
||||
is_profile_locked,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
@@ -2122,6 +2220,7 @@ mod tests {
|
||||
"generate_sample_fingerprint",
|
||||
"cloud_get_wayfern_token",
|
||||
"cloud_refresh_wayfern_token",
|
||||
"lock_profile",
|
||||
];
|
||||
|
||||
// Extract command names from the generate_handler! macro in this file
|
||||
|
||||
+88
-42
@@ -112,6 +112,17 @@ impl McpServer {
|
||||
|
||||
async fn require_paid_subscription(feature: &str) -> Result<(), McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
// Log the failed gate so customer logs explain why an MCP tool returned
|
||||
// an error. Include enough state (logged-in vs not, plan, status) for
|
||||
// support to diagnose without leaking secrets.
|
||||
let summary = match CLOUD_AUTH.get_user().await {
|
||||
Some(state) => format!(
|
||||
"logged_in=true plan={} status={} period={:?}",
|
||||
state.user.plan, state.user.subscription_status, state.user.plan_period,
|
||||
),
|
||||
None => "logged_in=false".to_string(),
|
||||
};
|
||||
log::warn!("[mcp] Rejected '{feature}' — paid subscription gate failed ({summary})");
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: format!("{feature} requires an active paid subscription"),
|
||||
@@ -1458,103 +1469,138 @@ impl McpServer {
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!({}));
|
||||
|
||||
// Surface the call in logs so customer reports show which tools the MCP
|
||||
// client is actually invoking (and therefore which gate any subsequent
|
||||
// error came from). Log only the tool name and the profile_id arg —
|
||||
// arbitrary URLs / JS / selectors can be sensitive.
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("<none>");
|
||||
log::info!("[mcp] tools/call name={tool_name} profile_id={profile_id}");
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let result = self.dispatch_tool_call(tool_name, &arguments).await;
|
||||
let elapsed_ms = started.elapsed().as_millis();
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
log::info!(
|
||||
"[mcp] tools/call name={tool_name} profile_id={profile_id} -> ok ({elapsed_ms} ms)"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[mcp] tools/call name={tool_name} profile_id={profile_id} -> error code={} msg={:?} ({elapsed_ms} ms)",
|
||||
e.code,
|
||||
e.message
|
||||
);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn dispatch_tool_call(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
match tool_name {
|
||||
"list_profiles" => self.handle_list_profiles().await,
|
||||
"get_profile" => self.handle_get_profile(&arguments).await,
|
||||
"get_profile" => self.handle_get_profile(arguments).await,
|
||||
"run_profile" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_run_profile(&arguments).await
|
||||
self.handle_run_profile(arguments).await
|
||||
}
|
||||
"kill_profile" => self.handle_kill_profile(&arguments).await,
|
||||
"create_profile" => self.handle_create_profile(&arguments).await,
|
||||
"update_profile" => self.handle_update_profile(&arguments).await,
|
||||
"delete_profile" => self.handle_delete_profile(&arguments).await,
|
||||
"kill_profile" => self.handle_kill_profile(arguments).await,
|
||||
"create_profile" => self.handle_create_profile(arguments).await,
|
||||
"update_profile" => self.handle_update_profile(arguments).await,
|
||||
"delete_profile" => self.handle_delete_profile(arguments).await,
|
||||
"list_tags" => self.handle_list_tags().await,
|
||||
"list_proxies" => self.handle_list_proxies().await,
|
||||
"get_profile_status" => self.handle_get_profile_status(&arguments).await,
|
||||
"get_profile_status" => self.handle_get_profile_status(arguments).await,
|
||||
// Group management
|
||||
"list_groups" => self.handle_list_groups().await,
|
||||
"get_group" => self.handle_get_group(&arguments).await,
|
||||
"create_group" => self.handle_create_group(&arguments).await,
|
||||
"update_group" => self.handle_update_group(&arguments).await,
|
||||
"delete_group" => self.handle_delete_group(&arguments).await,
|
||||
"assign_profiles_to_group" => self.handle_assign_profiles_to_group(&arguments).await,
|
||||
"get_group" => self.handle_get_group(arguments).await,
|
||||
"create_group" => self.handle_create_group(arguments).await,
|
||||
"update_group" => self.handle_update_group(arguments).await,
|
||||
"delete_group" => self.handle_delete_group(arguments).await,
|
||||
"assign_profiles_to_group" => self.handle_assign_profiles_to_group(arguments).await,
|
||||
// Full proxy management
|
||||
"get_proxy" => self.handle_get_proxy(&arguments).await,
|
||||
"create_proxy" => self.handle_create_proxy(&arguments).await,
|
||||
"update_proxy" => self.handle_update_proxy(&arguments).await,
|
||||
"delete_proxy" => self.handle_delete_proxy(&arguments).await,
|
||||
"get_proxy" => self.handle_get_proxy(arguments).await,
|
||||
"create_proxy" => self.handle_create_proxy(arguments).await,
|
||||
"update_proxy" => self.handle_update_proxy(arguments).await,
|
||||
"delete_proxy" => self.handle_delete_proxy(arguments).await,
|
||||
// Proxy import/export
|
||||
"export_proxies" => self.handle_export_proxies(&arguments).await,
|
||||
"import_proxies" => self.handle_import_proxies(&arguments).await,
|
||||
"export_proxies" => self.handle_export_proxies(arguments).await,
|
||||
"import_proxies" => self.handle_import_proxies(arguments).await,
|
||||
// VPN management
|
||||
"import_vpn" => self.handle_import_vpn(&arguments).await,
|
||||
"import_vpn" => self.handle_import_vpn(arguments).await,
|
||||
"list_vpn_configs" => self.handle_list_vpn_configs().await,
|
||||
"delete_vpn" => self.handle_delete_vpn(&arguments).await,
|
||||
"connect_vpn" => self.handle_connect_vpn(&arguments).await,
|
||||
"disconnect_vpn" => self.handle_disconnect_vpn(&arguments).await,
|
||||
"get_vpn_status" => self.handle_get_vpn_status(&arguments).await,
|
||||
"delete_vpn" => self.handle_delete_vpn(arguments).await,
|
||||
"connect_vpn" => self.handle_connect_vpn(arguments).await,
|
||||
"disconnect_vpn" => self.handle_disconnect_vpn(arguments).await,
|
||||
"get_vpn_status" => self.handle_get_vpn_status(arguments).await,
|
||||
// Fingerprint management
|
||||
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(&arguments).await,
|
||||
"update_profile_fingerprint" => self.handle_update_profile_fingerprint(&arguments).await,
|
||||
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(arguments).await,
|
||||
"update_profile_fingerprint" => self.handle_update_profile_fingerprint(arguments).await,
|
||||
"update_profile_proxy_bypass_rules" => {
|
||||
self
|
||||
.handle_update_profile_proxy_bypass_rules(&arguments)
|
||||
.handle_update_profile_proxy_bypass_rules(arguments)
|
||||
.await
|
||||
}
|
||||
// DNS blocklist management
|
||||
"update_profile_dns_blocklist" => self.handle_update_profile_dns_blocklist(&arguments).await,
|
||||
"update_profile_dns_blocklist" => self.handle_update_profile_dns_blocklist(arguments).await,
|
||||
"get_dns_blocklist_status" => self.handle_get_dns_blocklist_status().await,
|
||||
// Extension management
|
||||
"list_extensions" => self.handle_list_extensions().await,
|
||||
"list_extension_groups" => self.handle_list_extension_groups().await,
|
||||
"create_extension_group" => self.handle_create_extension_group(&arguments).await,
|
||||
"delete_extension" => self.handle_delete_extension_mcp(&arguments).await,
|
||||
"delete_extension_group" => self.handle_delete_extension_group_mcp(&arguments).await,
|
||||
"create_extension_group" => self.handle_create_extension_group(arguments).await,
|
||||
"delete_extension" => self.handle_delete_extension_mcp(arguments).await,
|
||||
"delete_extension_group" => self.handle_delete_extension_group_mcp(arguments).await,
|
||||
"assign_extension_group_to_profile" => {
|
||||
self
|
||||
.handle_assign_extension_group_to_profile(&arguments)
|
||||
.handle_assign_extension_group_to_profile(arguments)
|
||||
.await
|
||||
}
|
||||
// Team lock tools
|
||||
"get_team_locks" => self.handle_get_team_locks().await,
|
||||
"get_team_lock_status" => self.handle_get_team_lock_status(&arguments).await,
|
||||
"get_team_lock_status" => self.handle_get_team_lock_status(arguments).await,
|
||||
// Synchronizer tools
|
||||
"start_sync_session" => {
|
||||
Self::require_paid_subscription("Synchronizer").await?;
|
||||
self.handle_start_sync_session(&arguments).await
|
||||
self.handle_start_sync_session(arguments).await
|
||||
}
|
||||
"stop_sync_session" => self.handle_stop_sync_session(&arguments).await,
|
||||
"stop_sync_session" => self.handle_stop_sync_session(arguments).await,
|
||||
"get_sync_sessions" => self.handle_get_sync_sessions().await,
|
||||
"remove_sync_follower" => self.handle_remove_sync_follower(&arguments).await,
|
||||
"remove_sync_follower" => self.handle_remove_sync_follower(arguments).await,
|
||||
// Browser interaction tools (require paid subscription)
|
||||
"navigate" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_navigate(&arguments).await
|
||||
self.handle_navigate(arguments).await
|
||||
}
|
||||
"screenshot" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_screenshot(&arguments).await
|
||||
self.handle_screenshot(arguments).await
|
||||
}
|
||||
"evaluate_javascript" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_evaluate_javascript(&arguments).await
|
||||
self.handle_evaluate_javascript(arguments).await
|
||||
}
|
||||
"click_element" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_click_element(&arguments).await
|
||||
self.handle_click_element(arguments).await
|
||||
}
|
||||
"type_text" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_type_text(&arguments).await
|
||||
self.handle_type_text(arguments).await
|
||||
}
|
||||
"get_page_content" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_get_page_content(&arguments).await
|
||||
self.handle_get_page_content(arguments).await
|
||||
}
|
||||
"get_page_info" => {
|
||||
Self::require_paid_subscription("Browser automation").await?;
|
||||
self.handle_get_page_info(&arguments).await
|
||||
self.handle_get_page_info(arguments).await
|
||||
}
|
||||
_ => Err(McpError {
|
||||
code: -32602,
|
||||
|
||||
@@ -0,0 +1,702 @@
|
||||
//! Per-file encryption for password-protected profiles.
|
||||
//!
|
||||
//! Each on-disk file in `profiles/{uuid}/profile/` has:
|
||||
//! - **Filename**: `urlsafe_no_pad(HMAC-SHA256(profile_key, plaintext_relpath))[..32]`.
|
||||
//! Deterministic so cross-machine sync sees stable filenames; same plaintext
|
||||
//! path with same key always produces the same on-disk name.
|
||||
//! - **Content**: `nonce(12B) || AES-256-GCM(profile_key, path_len(2B-LE) || plaintext_path || file_bytes)`.
|
||||
//! The plaintext relpath is encoded inside the ciphertext so a launch can
|
||||
//! reconstruct the directory tree without a separate manifest.
|
||||
//!
|
||||
//! Wrong password fails the AES-GCM auth tag on the first decrypt, which
|
||||
//! doubles as password verification.
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use globset::{Glob, GlobSet, GlobSetBuilder};
|
||||
use ring::hmac;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use crate::sync::encryption::{decrypt_bytes, derive_profile_key, encrypt_bytes, generate_salt};
|
||||
|
||||
/// Length of the on-disk HMAC filename in chars.
|
||||
const HMAC_FILENAME_LEN: usize = 32;
|
||||
|
||||
/// Marker file written into encrypted profile dirs so launch code can verify
|
||||
/// the password before attempting to decrypt actual user data files.
|
||||
const VERIFY_FILE_NAME: &str = ".donut-pw-verify";
|
||||
const VERIFY_FILE_PATH: &str = "__donut_pw_verify__";
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// In-memory cache of derived per-profile encryption keys, keyed by profile UUID.
|
||||
/// Only populated while a profile is unlocked / running. Never persisted.
|
||||
static ref KEY_CACHE: Mutex<HashMap<uuid::Uuid, [u8; 32]>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PasswordError {
|
||||
#[error("io error: {0}")]
|
||||
Io(String),
|
||||
#[error("encryption error: {0}")]
|
||||
Encryption(String),
|
||||
#[error("invalid password")]
|
||||
WrongPassword,
|
||||
#[error("invalid file format")]
|
||||
InvalidFormat,
|
||||
}
|
||||
|
||||
pub type PasswordResult<T> = Result<T, PasswordError>;
|
||||
|
||||
impl From<std::io::Error> for PasswordError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
PasswordError::Io(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the HMAC-SHA256 derived on-disk filename for a plaintext relative path.
|
||||
pub fn hmac_filename(key: &[u8; 32], plaintext_relpath: &str) -> String {
|
||||
let signing_key = hmac::Key::new(hmac::HMAC_SHA256, key);
|
||||
let tag = hmac::sign(&signing_key, plaintext_relpath.as_bytes());
|
||||
let encoded = URL_SAFE_NO_PAD.encode(tag.as_ref());
|
||||
encoded.chars().take(HMAC_FILENAME_LEN).collect()
|
||||
}
|
||||
|
||||
/// Encrypt a single file's contents with its plaintext relative path embedded.
|
||||
pub fn encrypt_profile_file(
|
||||
key: &[u8; 32],
|
||||
plaintext_relpath: &str,
|
||||
file_bytes: &[u8],
|
||||
) -> PasswordResult<Vec<u8>> {
|
||||
let path_bytes = plaintext_relpath.as_bytes();
|
||||
if path_bytes.len() > u16::MAX as usize {
|
||||
return Err(PasswordError::Encryption("relpath too long".into()));
|
||||
}
|
||||
let mut plaintext = Vec::with_capacity(2 + path_bytes.len() + file_bytes.len());
|
||||
plaintext.extend_from_slice(&(path_bytes.len() as u16).to_le_bytes());
|
||||
plaintext.extend_from_slice(path_bytes);
|
||||
plaintext.extend_from_slice(file_bytes);
|
||||
encrypt_bytes(key, &plaintext).map_err(PasswordError::Encryption)
|
||||
}
|
||||
|
||||
/// Decrypt one file's bytes back into `(plaintext_relpath, file_bytes)`.
|
||||
pub fn decrypt_profile_file(
|
||||
key: &[u8; 32],
|
||||
encrypted_bytes: &[u8],
|
||||
) -> PasswordResult<(String, Vec<u8>)> {
|
||||
let plaintext = decrypt_bytes(key, encrypted_bytes).map_err(|_| PasswordError::WrongPassword)?;
|
||||
if plaintext.len() < 2 {
|
||||
return Err(PasswordError::InvalidFormat);
|
||||
}
|
||||
let path_len = u16::from_le_bytes([plaintext[0], plaintext[1]]) as usize;
|
||||
if plaintext.len() < 2 + path_len {
|
||||
return Err(PasswordError::InvalidFormat);
|
||||
}
|
||||
let path = std::str::from_utf8(&plaintext[2..2 + path_len])
|
||||
.map_err(|_| PasswordError::InvalidFormat)?
|
||||
.to_string();
|
||||
let content = plaintext[2 + path_len..].to_vec();
|
||||
Ok((path, content))
|
||||
}
|
||||
|
||||
fn build_excludes(patterns: &[&str]) -> GlobSet {
|
||||
let mut builder = GlobSetBuilder::new();
|
||||
for p in patterns {
|
||||
if let Ok(g) = Glob::new(p) {
|
||||
builder.add(g);
|
||||
}
|
||||
}
|
||||
builder.build().unwrap_or_else(|_| GlobSet::empty())
|
||||
}
|
||||
|
||||
fn walk_files(
|
||||
base: &Path,
|
||||
current: &Path,
|
||||
excludes: &GlobSet,
|
||||
out: &mut Vec<(String, PathBuf)>,
|
||||
) -> std::io::Result<()> {
|
||||
for entry in std::fs::read_dir(current)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let relative = path
|
||||
.strip_prefix(base)
|
||||
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
|
||||
if excludes.is_match(&relative) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let metadata = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if metadata.is_dir() {
|
||||
walk_files(base, &path, excludes, out)?;
|
||||
} else if metadata.is_file() {
|
||||
out.push((relative, path));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = path.with_extension("donut-tmp");
|
||||
std::fs::write(&tmp, data)?;
|
||||
std::fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
fn write_verifier(key: &[u8; 32], encrypted_dir: &Path) -> PasswordResult<()> {
|
||||
let encrypted = encrypt_profile_file(key, VERIFY_FILE_PATH, b"donut-verify")?;
|
||||
let path = encrypted_dir.join(VERIFY_FILE_NAME);
|
||||
atomic_write(&path, &encrypted)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify a derived key against an encrypted profile dir. Returns Ok(()) on
|
||||
/// success, `Err(WrongPassword)` if the password is wrong, or another error
|
||||
/// for I/O / format problems.
|
||||
pub fn verify_key_against_dir(key: &[u8; 32], encrypted_dir: &Path) -> PasswordResult<()> {
|
||||
let path = encrypted_dir.join(VERIFY_FILE_NAME);
|
||||
if !path.exists() {
|
||||
return Err(PasswordError::InvalidFormat);
|
||||
}
|
||||
let bytes = std::fs::read(&path)?;
|
||||
let (relpath, content) = decrypt_profile_file(key, &bytes)?;
|
||||
if relpath != VERIFY_FILE_PATH || content != b"donut-verify" {
|
||||
return Err(PasswordError::InvalidFormat);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encrypt every file under `plaintext_dir` into `encrypted_dir`, replacing
|
||||
/// it. Files matching `exclude_patterns` are dropped.
|
||||
pub fn encrypt_profile_dir(
|
||||
key: &[u8; 32],
|
||||
plaintext_dir: &Path,
|
||||
encrypted_dir: &Path,
|
||||
exclude_patterns: &[&str],
|
||||
) -> PasswordResult<()> {
|
||||
if encrypted_dir.exists() {
|
||||
std::fs::remove_dir_all(encrypted_dir)?;
|
||||
}
|
||||
std::fs::create_dir_all(encrypted_dir)?;
|
||||
|
||||
let excludes = build_excludes(exclude_patterns);
|
||||
let mut files = Vec::new();
|
||||
if plaintext_dir.exists() {
|
||||
walk_files(plaintext_dir, plaintext_dir, &excludes, &mut files)?;
|
||||
}
|
||||
|
||||
for (relpath, abs) in files {
|
||||
let bytes = std::fs::read(&abs)?;
|
||||
let encrypted = encrypt_profile_file(key, &relpath, &bytes)?;
|
||||
let on_disk = encrypted_dir.join(hmac_filename(key, &relpath));
|
||||
atomic_write(&on_disk, &encrypted)?;
|
||||
}
|
||||
|
||||
write_verifier(key, encrypted_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decrypt every file in `encrypted_dir` back into `plaintext_dir` (which is
|
||||
/// created if missing). Returns the per-file mtimes captured after writing,
|
||||
/// keyed by plaintext relpath. Caller can use them as the "before-launch"
|
||||
/// snapshot to skip unchanged files on re-encrypt.
|
||||
pub fn decrypt_profile_dir(
|
||||
key: &[u8; 32],
|
||||
encrypted_dir: &Path,
|
||||
plaintext_dir: &Path,
|
||||
) -> PasswordResult<HashMap<String, SystemTime>> {
|
||||
std::fs::create_dir_all(plaintext_dir)?;
|
||||
let mut mtimes = HashMap::new();
|
||||
|
||||
let entries: Vec<_> = std::fs::read_dir(encrypted_dir)?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
if name == VERIFY_FILE_NAME {
|
||||
continue;
|
||||
}
|
||||
let bytes = std::fs::read(&path)?;
|
||||
let (relpath, content) = decrypt_profile_file(key, &bytes)?;
|
||||
let dest = plaintext_dir.join(&relpath);
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&dest, &content)?;
|
||||
if let Ok(m) = dest.metadata().and_then(|m| m.modified()) {
|
||||
mtimes.insert(relpath, m);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(mtimes)
|
||||
}
|
||||
|
||||
/// Re-encrypt the contents of `plaintext_dir` back into `encrypted_dir`,
|
||||
/// preserving on-disk filenames for files whose plaintext content didn't
|
||||
/// change. Returns the number of files re-encrypted.
|
||||
///
|
||||
/// `before_launch_mtimes` is the snapshot captured by `decrypt_profile_dir`.
|
||||
/// Files whose mtime hasn't moved are left untouched on disk.
|
||||
pub fn reencrypt_changed_files(
|
||||
key: &[u8; 32],
|
||||
plaintext_dir: &Path,
|
||||
encrypted_dir: &Path,
|
||||
exclude_patterns: &[&str],
|
||||
before_launch_mtimes: &HashMap<String, SystemTime>,
|
||||
) -> PasswordResult<usize> {
|
||||
std::fs::create_dir_all(encrypted_dir)?;
|
||||
let excludes = build_excludes(exclude_patterns);
|
||||
|
||||
let mut current_files = Vec::new();
|
||||
if plaintext_dir.exists() {
|
||||
walk_files(plaintext_dir, plaintext_dir, &excludes, &mut current_files)?;
|
||||
}
|
||||
|
||||
let mut current_paths: HashSet<String> = HashSet::new();
|
||||
let mut rewrote = 0usize;
|
||||
for (relpath, abs) in current_files {
|
||||
current_paths.insert(relpath.clone());
|
||||
|
||||
let cur_mtime = abs.metadata().and_then(|m| m.modified()).ok();
|
||||
let unchanged = match (cur_mtime, before_launch_mtimes.get(&relpath)) {
|
||||
(Some(now), Some(before)) => now == *before,
|
||||
_ => false,
|
||||
};
|
||||
if unchanged {
|
||||
continue;
|
||||
}
|
||||
|
||||
let bytes = std::fs::read(&abs)?;
|
||||
let encrypted = encrypt_profile_file(key, &relpath, &bytes)?;
|
||||
let on_disk = encrypted_dir.join(hmac_filename(key, &relpath));
|
||||
atomic_write(&on_disk, &encrypted)?;
|
||||
rewrote += 1;
|
||||
}
|
||||
|
||||
// Delete on-disk files for plaintext paths that no longer exist
|
||||
let valid_names: HashSet<String> = current_paths
|
||||
.iter()
|
||||
.map(|p| hmac_filename(key, p))
|
||||
.collect();
|
||||
|
||||
for entry in std::fs::read_dir(encrypted_dir)?.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
if name == VERIFY_FILE_NAME {
|
||||
continue;
|
||||
}
|
||||
if !valid_names.contains(&name) {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
write_verifier(key, encrypted_dir)?;
|
||||
Ok(rewrote)
|
||||
}
|
||||
|
||||
/// Re-encrypt every file under `encrypted_dir` from `old_key` to `new_key` in
|
||||
/// place. Used when changing a profile password without launching it.
|
||||
pub fn rekey_profile_dir(
|
||||
old_key: &[u8; 32],
|
||||
new_key: &[u8; 32],
|
||||
encrypted_dir: &Path,
|
||||
) -> PasswordResult<()> {
|
||||
let entries: Vec<_> = std::fs::read_dir(encrypted_dir)?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
let mut decrypted: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
for entry in &entries {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n,
|
||||
None => continue,
|
||||
};
|
||||
if name == VERIFY_FILE_NAME {
|
||||
continue;
|
||||
}
|
||||
let bytes = std::fs::read(&path)?;
|
||||
let (relpath, content) = decrypt_profile_file(old_key, &bytes)?;
|
||||
decrypted.push((relpath, content));
|
||||
}
|
||||
|
||||
// Decryption succeeded for every file; safe to rewrite the directory.
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
for (relpath, content) in decrypted {
|
||||
let encrypted = encrypt_profile_file(new_key, &relpath, &content)?;
|
||||
let on_disk = encrypted_dir.join(hmac_filename(new_key, &relpath));
|
||||
atomic_write(&on_disk, &encrypted)?;
|
||||
}
|
||||
|
||||
write_verifier(new_key, encrypted_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- key cache ----------
|
||||
|
||||
pub fn cache_key(profile_id: uuid::Uuid, key: [u8; 32]) {
|
||||
if let Ok(mut guard) = KEY_CACHE.lock() {
|
||||
guard.insert(profile_id, key);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_cached_key(profile_id: &uuid::Uuid) -> Option<[u8; 32]> {
|
||||
KEY_CACHE.lock().ok()?.get(profile_id).copied()
|
||||
}
|
||||
|
||||
pub fn drop_cached_key(profile_id: &uuid::Uuid) {
|
||||
if let Ok(mut guard) = KEY_CACHE.lock() {
|
||||
guard.remove(profile_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_cached_key(profile_id: &uuid::Uuid) -> bool {
|
||||
KEY_CACHE
|
||||
.lock()
|
||||
.map(|g| g.contains_key(profile_id))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Convenience: derive + verify against the encrypted dir + cache the key on success.
|
||||
pub fn unlock(
|
||||
profile_id: uuid::Uuid,
|
||||
password: &str,
|
||||
salt: &str,
|
||||
encrypted_dir: &Path,
|
||||
) -> PasswordResult<()> {
|
||||
let key = derive_profile_key(password, salt).map_err(PasswordError::Encryption)?;
|
||||
verify_key_against_dir(&key, encrypted_dir)?;
|
||||
cache_key(profile_id, key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn fresh_salt() -> String {
|
||||
generate_salt()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_key() -> [u8; 32] {
|
||||
derive_profile_key("hunter2", &generate_salt()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_filename_deterministic() {
|
||||
let key = [7u8; 32];
|
||||
let a = hmac_filename(&key, "Default/Cookies");
|
||||
let b = hmac_filename(&key, "Default/Cookies");
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(a.len(), HMAC_FILENAME_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_filename_different_keys() {
|
||||
let a = hmac_filename(&[1u8; 32], "Default/Cookies");
|
||||
let b = hmac_filename(&[2u8; 32], "Default/Cookies");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_filename_different_paths() {
|
||||
let key = [1u8; 32];
|
||||
let a = hmac_filename(&key, "Default/Cookies");
|
||||
let b = hmac_filename(&key, "Default/Login Data");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_roundtrip() {
|
||||
let key = make_key();
|
||||
let original = b"hello world".to_vec();
|
||||
let encrypted = encrypt_profile_file(&key, "Default/Cookies", &original).unwrap();
|
||||
let (path, content) = decrypt_profile_file(&key, &encrypted).unwrap();
|
||||
assert_eq!(path, "Default/Cookies");
|
||||
assert_eq!(content, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_wrong_key_fails() {
|
||||
let key1 = make_key();
|
||||
let key2 = make_key();
|
||||
let encrypted = encrypt_profile_file(&key1, "Cookies", b"data").unwrap();
|
||||
assert!(matches!(
|
||||
decrypt_profile_file(&key2, &encrypted),
|
||||
Err(PasswordError::WrongPassword)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_truncated_ciphertext() {
|
||||
let key = make_key();
|
||||
let encrypted = encrypt_profile_file(&key, "x", b"y").unwrap();
|
||||
// Drop the auth tag
|
||||
let truncated = &encrypted[..encrypted.len() - 1];
|
||||
assert!(decrypt_profile_file(&key, truncated).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dir_roundtrip() {
|
||||
let key = make_key();
|
||||
let work = TempDir::new().unwrap();
|
||||
let plain = work.path().join("plain");
|
||||
let enc = work.path().join("enc");
|
||||
std::fs::create_dir_all(plain.join("Default")).unwrap();
|
||||
std::fs::write(plain.join("Default/Cookies"), b"sqlite-data").unwrap();
|
||||
std::fs::write(plain.join("Default/Bookmarks"), b"{\"x\":1}").unwrap();
|
||||
std::fs::write(plain.join("Local State"), b"state").unwrap();
|
||||
|
||||
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
|
||||
|
||||
// No plaintext filenames on disk
|
||||
let names: Vec<String> = std::fs::read_dir(&enc)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
for n in &names {
|
||||
assert!(!n.contains("Cookies"), "plaintext leaked: {n}");
|
||||
assert!(!n.contains("Bookmarks"));
|
||||
assert!(!n.contains("Local State"));
|
||||
}
|
||||
|
||||
// Verify file present
|
||||
assert!(enc.join(VERIFY_FILE_NAME).exists());
|
||||
|
||||
let restored = work.path().join("restored");
|
||||
let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap();
|
||||
assert_eq!(mtimes.len(), 3);
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(restored.join("Default/Cookies")).unwrap(),
|
||||
b"sqlite-data"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(restored.join("Default/Bookmarks")).unwrap(),
|
||||
b"{\"x\":1}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(restored.join("Local State")).unwrap(),
|
||||
b"state"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dir_excludes() {
|
||||
let key = make_key();
|
||||
let work = TempDir::new().unwrap();
|
||||
let plain = work.path().join("plain");
|
||||
let enc = work.path().join("enc");
|
||||
std::fs::create_dir_all(plain.join("Default/Cache")).unwrap();
|
||||
std::fs::write(plain.join("Default/Cookies"), b"keep").unwrap();
|
||||
std::fs::write(plain.join("Default/Cache/data"), b"drop").unwrap();
|
||||
|
||||
encrypt_profile_dir(&key, &plain, &enc, &["**/Cache/**"]).unwrap();
|
||||
|
||||
let restored = work.path().join("restored");
|
||||
let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap();
|
||||
|
||||
// Only Cookies (1 file) should be present, not Cache contents
|
||||
assert_eq!(mtimes.len(), 1);
|
||||
assert!(mtimes.contains_key("Default/Cookies"));
|
||||
assert!(restored.join("Default/Cookies").exists());
|
||||
assert!(!restored.join("Default/Cache/data").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_against_wrong_key() {
|
||||
let key1 = make_key();
|
||||
let key2 = make_key();
|
||||
let work = TempDir::new().unwrap();
|
||||
let plain = work.path().join("plain");
|
||||
let enc = work.path().join("enc");
|
||||
std::fs::create_dir_all(&plain).unwrap();
|
||||
std::fs::write(plain.join("file"), b"data").unwrap();
|
||||
encrypt_profile_dir(&key1, &plain, &enc, &[]).unwrap();
|
||||
assert!(verify_key_against_dir(&key1, &enc).is_ok());
|
||||
assert!(matches!(
|
||||
verify_key_against_dir(&key2, &enc),
|
||||
Err(PasswordError::WrongPassword)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reencrypt_skips_unchanged() {
|
||||
let key = make_key();
|
||||
let work = TempDir::new().unwrap();
|
||||
let plain = work.path().join("plain");
|
||||
let enc = work.path().join("enc");
|
||||
std::fs::create_dir_all(&plain).unwrap();
|
||||
std::fs::write(plain.join("a"), b"AAA").unwrap();
|
||||
std::fs::write(plain.join("b"), b"BBB").unwrap();
|
||||
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
|
||||
|
||||
let restored = work.path().join("restored");
|
||||
let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap();
|
||||
|
||||
// Capture pre-rewrite ciphertext bytes
|
||||
let name_a = hmac_filename(&key, "a");
|
||||
let name_b = hmac_filename(&key, "b");
|
||||
let cipher_a_before = std::fs::read(enc.join(&name_a)).unwrap();
|
||||
let cipher_b_before = std::fs::read(enc.join(&name_b)).unwrap();
|
||||
|
||||
// Modify only "a" in the restored tree
|
||||
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||
std::fs::write(restored.join("a"), b"AAA-CHANGED").unwrap();
|
||||
|
||||
let rewrote = reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap();
|
||||
assert_eq!(rewrote, 1);
|
||||
|
||||
let cipher_a_after = std::fs::read(enc.join(&name_a)).unwrap();
|
||||
let cipher_b_after = std::fs::read(enc.join(&name_b)).unwrap();
|
||||
assert_ne!(
|
||||
cipher_a_before, cipher_a_after,
|
||||
"changed file should have new ciphertext"
|
||||
);
|
||||
assert_eq!(
|
||||
cipher_b_before, cipher_b_after,
|
||||
"unchanged file should have stable ciphertext"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reencrypt_handles_added_and_removed() {
|
||||
let key = make_key();
|
||||
let work = TempDir::new().unwrap();
|
||||
let plain = work.path().join("plain");
|
||||
let enc = work.path().join("enc");
|
||||
std::fs::create_dir_all(&plain).unwrap();
|
||||
std::fs::write(plain.join("keep"), b"k").unwrap();
|
||||
std::fs::write(plain.join("delete"), b"d").unwrap();
|
||||
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
|
||||
|
||||
let restored = work.path().join("restored");
|
||||
let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap();
|
||||
|
||||
std::fs::remove_file(restored.join("delete")).unwrap();
|
||||
std::fs::write(restored.join("new"), b"n").unwrap();
|
||||
|
||||
reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap();
|
||||
|
||||
let names: HashSet<String> = std::fs::read_dir(&enc)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
|
||||
assert!(names.contains(&hmac_filename(&key, "keep")));
|
||||
assert!(names.contains(&hmac_filename(&key, "new")));
|
||||
assert!(!names.contains(&hmac_filename(&key, "delete")));
|
||||
assert!(names.contains(VERIFY_FILE_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rekey_changes_filenames_and_content() {
|
||||
let old = make_key();
|
||||
let new = make_key();
|
||||
let work = TempDir::new().unwrap();
|
||||
let plain = work.path().join("plain");
|
||||
let enc = work.path().join("enc");
|
||||
std::fs::create_dir_all(&plain).unwrap();
|
||||
std::fs::write(plain.join("x"), b"data").unwrap();
|
||||
encrypt_profile_dir(&old, &plain, &enc, &[]).unwrap();
|
||||
|
||||
let old_name = hmac_filename(&old, "x");
|
||||
let new_name = hmac_filename(&new, "x");
|
||||
assert_ne!(old_name, new_name);
|
||||
|
||||
rekey_profile_dir(&old, &new, &enc).unwrap();
|
||||
|
||||
assert!(!enc.join(&old_name).exists());
|
||||
assert!(enc.join(&new_name).exists());
|
||||
verify_key_against_dir(&new, &enc).unwrap();
|
||||
assert!(matches!(
|
||||
verify_key_against_dir(&old, &enc),
|
||||
Err(PasswordError::WrongPassword)
|
||||
));
|
||||
|
||||
let restored = work.path().join("restored");
|
||||
decrypt_profile_dir(&new, &enc, &restored).unwrap();
|
||||
assert_eq!(std::fs::read(restored.join("x")).unwrap(), b"data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_atomic_write_leaves_original_intact_if_tmp_lingers() {
|
||||
let work = TempDir::new().unwrap();
|
||||
let target = work.path().join("file");
|
||||
std::fs::write(&target, b"original").unwrap();
|
||||
|
||||
// Simulate a stale tmp from a crashed write
|
||||
std::fs::write(target.with_extension("donut-tmp"), b"partial").unwrap();
|
||||
|
||||
// A successful write should overwrite the original even when stale tmp exists
|
||||
atomic_write(&target, b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_cache_lifecycle() {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
assert!(!has_cached_key(&id));
|
||||
cache_key(id, [9u8; 32]);
|
||||
assert!(has_cached_key(&id));
|
||||
assert_eq!(get_cached_key(&id), Some([9u8; 32]));
|
||||
drop_cached_key(&id);
|
||||
assert!(!has_cached_key(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unlock_helper() {
|
||||
let work = TempDir::new().unwrap();
|
||||
let plain = work.path().join("plain");
|
||||
let enc = work.path().join("enc");
|
||||
std::fs::create_dir_all(&plain).unwrap();
|
||||
std::fs::write(plain.join("x"), b"data").unwrap();
|
||||
|
||||
let salt = generate_salt();
|
||||
let key = derive_profile_key("correct horse", &salt).unwrap();
|
||||
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
|
||||
|
||||
let id = uuid::Uuid::new_v4();
|
||||
drop_cached_key(&id);
|
||||
assert!(unlock(id, "wrong", &salt, &enc).is_err());
|
||||
assert!(!has_cached_key(&id));
|
||||
assert!(unlock(id, "correct horse", &salt, &enc).is_ok());
|
||||
assert!(has_cached_key(&id));
|
||||
drop_cached_key(&id);
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,8 @@ impl ProfileManager {
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -285,6 +287,8 @@ impl ProfileManager {
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -340,6 +344,13 @@ impl ProfileManager {
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist,
|
||||
password_protected: false,
|
||||
created_at: Some(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
),
|
||||
};
|
||||
|
||||
// Save profile info
|
||||
@@ -642,7 +653,7 @@ impl ProfileManager {
|
||||
|
||||
pub fn assign_profiles_to_group(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
profile_ids: Vec<String>,
|
||||
group_id: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -671,10 +682,8 @@ impl ProfileManager {
|
||||
if profile.is_sync_enabled() {
|
||||
if let Some(ref new_group_id) = group_id {
|
||||
let group_id_clone = new_group_id.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _ =
|
||||
crate::sync::enable_group_sync_if_needed(&group_id_clone, &app_handle_clone).await;
|
||||
let _ = crate::sync::enable_group_sync_if_needed(&group_id_clone).await;
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
scheduler.queue_group_sync(group_id_clone).await;
|
||||
}
|
||||
@@ -987,6 +996,13 @@ impl ProfileManager {
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: source.dns_blocklist,
|
||||
password_protected: false,
|
||||
created_at: Some(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
),
|
||||
};
|
||||
|
||||
self.save_profile(&new_profile)?;
|
||||
@@ -1120,7 +1136,7 @@ impl ProfileManager {
|
||||
|
||||
pub async fn update_profile_proxy(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
_app_handle: tauri::AppHandle,
|
||||
profile_id: &str,
|
||||
proxy_id: Option<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -1161,7 +1177,7 @@ impl ProfileManager {
|
||||
// Auto-enable sync for new proxy if profile has sync enabled
|
||||
if profile.is_sync_enabled() {
|
||||
if let Some(ref new_proxy_id) = proxy_id {
|
||||
let _ = crate::sync::enable_proxy_sync_if_needed(new_proxy_id, &app_handle).await;
|
||||
let _ = crate::sync::enable_proxy_sync_if_needed(new_proxy_id).await;
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
scheduler.queue_proxy_sync(new_proxy_id.clone()).await;
|
||||
}
|
||||
@@ -1238,7 +1254,7 @@ impl ProfileManager {
|
||||
})?;
|
||||
|
||||
// Update VPN and clear proxy (mutual exclusion)
|
||||
profile.vpn_id = vpn_id;
|
||||
profile.vpn_id = vpn_id.clone();
|
||||
profile.proxy_id = None;
|
||||
|
||||
self
|
||||
@@ -1247,6 +1263,16 @@ impl ProfileManager {
|
||||
format!("Failed to save profile: {e}").into()
|
||||
})?;
|
||||
|
||||
// Auto-enable sync for the new VPN if profile has sync enabled.
|
||||
if profile.is_sync_enabled() {
|
||||
if let Some(ref new_vpn_id) = vpn_id {
|
||||
let _ = crate::sync::enable_vpn_sync_if_needed(new_vpn_id).await;
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
scheduler.queue_vpn_sync(new_vpn_id.clone()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = events::emit("profile-updated", &profile) {
|
||||
log::warn!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
@@ -1271,9 +1297,23 @@ impl ProfileManager {
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
profile.extension_group_id = extension_group_id;
|
||||
profile.extension_group_id = extension_group_id.clone();
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
// Auto-enable sync for the new extension group if profile has sync
|
||||
// enabled. The helper is sync internally; we fire-and-forget through
|
||||
// the async runtime so any I/O doesn't block this caller.
|
||||
if profile.is_sync_enabled() {
|
||||
if let Some(new_group_id) = extension_group_id {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _ = crate::sync::enable_extension_group_sync_if_needed(&new_group_id).await;
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
scheduler.queue_extension_group_sync(new_group_id).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = events::emit("profile-updated", &profile) {
|
||||
log::warn!("Failed to emit profile update event: {e}");
|
||||
}
|
||||
@@ -2056,6 +2096,38 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("http or https"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_launch_hook_accepts_https_url() {
|
||||
let result = super::validate_launch_hook(Some("https://example.com/track")).unwrap();
|
||||
assert_eq!(result.as_deref(), Some("https://example.com/track"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_launch_hook_rejects_garbage_with_code() {
|
||||
let err = super::validate_launch_hook(Some("not a url")).unwrap_err();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&err).expect("error must be JSON");
|
||||
assert_eq!(parsed["code"], "INVALID_LAUNCH_HOOK_URL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_launch_hook_rejects_non_http_scheme_with_code() {
|
||||
let err = super::validate_launch_hook(Some("ftp://example.com/hook")).unwrap_err();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&err).expect("error must be JSON");
|
||||
assert_eq!(parsed["code"], "INVALID_LAUNCH_HOOK_URL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_launch_hook_empty_clears_hook() {
|
||||
let result = super::validate_launch_hook(Some("")).unwrap();
|
||||
assert!(result.is_none());
|
||||
|
||||
let result_ws = super::validate_launch_hook(Some(" ")).unwrap();
|
||||
assert!(result_ws.is_none());
|
||||
|
||||
let result_none = super::validate_launch_hook(None).unwrap();
|
||||
assert!(result_none.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -2154,12 +2226,34 @@ pub fn update_profile_note(
|
||||
.map_err(|e| format!("Failed to update profile note: {e}"))
|
||||
}
|
||||
|
||||
/// Validate a launch hook value. Returns `Ok(None)` for "clear the hook"
|
||||
/// (`None`, empty, or whitespace-only), `Ok(Some(_))` for a valid http(s)
|
||||
/// URL, or `Err` with the `INVALID_LAUNCH_HOOK_URL` code payload.
|
||||
pub(crate) fn validate_launch_hook(launch_hook: Option<&str>) -> Result<Option<String>, String> {
|
||||
let Some(raw) = launch_hook else {
|
||||
return Ok(None);
|
||||
};
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let ok = url::Url::parse(trimmed)
|
||||
.ok()
|
||||
.map(|u| matches!(u.scheme(), "http" | "https"))
|
||||
.unwrap_or(false);
|
||||
if !ok {
|
||||
return Err(serde_json::json!({ "code": "INVALID_LAUNCH_HOOK_URL" }).to_string());
|
||||
}
|
||||
Ok(Some(trimmed.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_profile_launch_hook(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
launch_hook: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
validate_launch_hook(launch_hook.as_deref())?;
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.update_profile_launch_hook(&app_handle, &profile_id, launch_hook)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod encryption;
|
||||
pub mod manager;
|
||||
pub mod password;
|
||||
pub mod types;
|
||||
|
||||
pub use manager::ProfileManager;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -69,6 +69,15 @@ pub struct BrowserProfile {
|
||||
pub created_by_email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dns_blocklist: Option<String>,
|
||||
/// True when the on-disk profile dir is encrypted with a per-profile password.
|
||||
/// Decryption goes to a RAM-backed ephemeral dir, never to disk.
|
||||
#[serde(default)]
|
||||
pub password_protected: bool,
|
||||
/// Profile creation timestamp (epoch seconds, UTC). `None` for legacy
|
||||
/// profiles that pre-date this field — those are treated as ancient by
|
||||
/// any staleness check.
|
||||
#[serde(default)]
|
||||
pub created_at: Option<u64>,
|
||||
}
|
||||
|
||||
pub fn default_release_type() -> String {
|
||||
|
||||
@@ -584,6 +584,8 @@ impl ProfileImporter {
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -664,6 +666,8 @@ impl ProfileImporter {
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: None,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -715,6 +719,13 @@ impl ProfileImporter {
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
created_at: Some(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
),
|
||||
};
|
||||
|
||||
self.profile_manager.save_profile(&profile)?;
|
||||
|
||||
+87
-415
@@ -174,6 +174,10 @@ pub struct ProxyManager {
|
||||
// Track active proxy IDs by profile name for targeted cleanup
|
||||
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
|
||||
// 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 {
|
||||
@@ -183,6 +187,7 @@ impl ProxyManager {
|
||||
profile_proxies: Mutex::new(HashMap::new()),
|
||||
profile_active_proxy_ids: Mutex::new(HashMap::new()),
|
||||
stored_proxies: Mutex::new(HashMap::new()),
|
||||
dead_browser_misses: Mutex::new(HashMap::new()),
|
||||
};
|
||||
|
||||
// Load stored proxies on initialization
|
||||
@@ -825,6 +830,42 @@ impl ProxyManager {
|
||||
Ok(updated_proxy)
|
||||
}
|
||||
|
||||
/// Update the in-memory `sync_enabled` / `last_sync` fields of a stored
|
||||
/// proxy and persist the change to disk. Returns the updated proxy or
|
||||
/// `Err` if the proxy isn't found / is cloud-managed.
|
||||
///
|
||||
/// This is the canonical write path for sync-state changes — direct
|
||||
/// `fs::write` from a sync command would leave the in-memory cache
|
||||
/// (`stored_proxies`) stale, and the next `get_stored_proxies()` would
|
||||
/// return the old `sync_enabled`, breaking the UI toggle.
|
||||
pub fn set_stored_proxy_sync_state(
|
||||
&self,
|
||||
proxy_id: &str,
|
||||
sync_enabled: bool,
|
||||
last_sync: Option<u64>,
|
||||
) -> Result<StoredProxy, String> {
|
||||
let updated_proxy = {
|
||||
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
let proxy = stored_proxies
|
||||
.get_mut(proxy_id)
|
||||
.ok_or_else(|| format!("Proxy with ID '{proxy_id}' not found"))?;
|
||||
|
||||
if proxy.is_cloud_managed {
|
||||
return Err("Cannot modify sync for a cloud-managed proxy".to_string());
|
||||
}
|
||||
|
||||
proxy.sync_enabled = sync_enabled;
|
||||
proxy.last_sync = last_sync;
|
||||
proxy.clone()
|
||||
};
|
||||
|
||||
self
|
||||
.save_proxy(&updated_proxy)
|
||||
.map_err(|e| format!("Failed to save proxy: {e}"))?;
|
||||
|
||||
Ok(updated_proxy)
|
||||
}
|
||||
|
||||
// Delete a stored proxy
|
||||
pub fn delete_stored_proxy(
|
||||
&self,
|
||||
@@ -1074,149 +1115,6 @@ impl ProxyManager {
|
||||
self.load_proxy_check_cache(proxy_id)
|
||||
}
|
||||
|
||||
pub async fn fetch_proxy_from_url(
|
||||
&self,
|
||||
url: &str,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<Option<ProxySettings>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch launch hook: {e}"))?;
|
||||
|
||||
if response.status() == reqwest::StatusCode::NO_CONTENT {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Launch hook returned status {}", response.status()));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read launch hook response: {e}"))?;
|
||||
|
||||
let body = body.trim();
|
||||
if body.is_empty() {
|
||||
return Err("Launch hook returned empty response".to_string());
|
||||
}
|
||||
|
||||
if let Ok(settings) = Self::parse_dynamic_proxy_json(body) {
|
||||
return Ok(Some(settings));
|
||||
}
|
||||
|
||||
match Self::parse_dynamic_proxy_text(body) {
|
||||
Ok(settings) => Ok(Some(settings)),
|
||||
Err(text_error) => Err(format!(
|
||||
"Failed to parse launch hook response: {text_error}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON proxy payload: { "ip"/"host": "...", "port": ..., "username": "...", "password": "..." }
|
||||
fn parse_dynamic_proxy_json(body: &str) -> Result<ProxySettings, String> {
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(body).map_err(|e| format!("Invalid JSON response: {e}"))?;
|
||||
|
||||
let obj = json
|
||||
.as_object()
|
||||
.ok_or_else(|| "JSON response is not an object".to_string())?;
|
||||
|
||||
let raw_host = obj
|
||||
.get("ip")
|
||||
.or_else(|| obj.get("host"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing 'ip' or 'host' field in JSON response".to_string())?;
|
||||
|
||||
// Strip protocol prefix from host if present (e.g. "socks5://1.2.3.4" -> "1.2.3.4")
|
||||
// and extract the proxy type from it if no explicit type field is provided
|
||||
let (host, protocol_from_host) = if let Some(rest) = raw_host.strip_prefix("://") {
|
||||
(rest.to_string(), None)
|
||||
} else if let Some((proto, rest)) = raw_host.split_once("://") {
|
||||
(rest.to_string(), Some(proto.to_lowercase()))
|
||||
} else {
|
||||
(raw_host.to_string(), None)
|
||||
};
|
||||
|
||||
let port = obj
|
||||
.get("port")
|
||||
.and_then(|v| {
|
||||
v.as_u64()
|
||||
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
|
||||
})
|
||||
.ok_or_else(|| "Missing or invalid 'port' field in JSON response".to_string())?
|
||||
as u16;
|
||||
|
||||
let proxy_type = obj
|
||||
.get("type")
|
||||
.or_else(|| obj.get("proxy_type"))
|
||||
.or_else(|| obj.get("protocol"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_lowercase())
|
||||
.or(protocol_from_host)
|
||||
.unwrap_or_else(|| "http".to_string());
|
||||
|
||||
let username = obj
|
||||
.get("username")
|
||||
.or_else(|| obj.get("user"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let password = obj
|
||||
.get("password")
|
||||
.or_else(|| obj.get("pass"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Ok(ProxySettings {
|
||||
proxy_type,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
// Parse plain text proxy payload using the same logic as proxy import
|
||||
fn parse_dynamic_proxy_text(body: &str) -> Result<ProxySettings, String> {
|
||||
let line = body
|
||||
.lines()
|
||||
.find(|l| !l.trim().is_empty())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if line.is_empty() {
|
||||
return Err("Empty text response".to_string());
|
||||
}
|
||||
|
||||
match Self::parse_single_proxy_line(line) {
|
||||
ProxyParseResult::Parsed(parsed) => Ok(ProxySettings {
|
||||
proxy_type: parsed.proxy_type,
|
||||
host: parsed.host,
|
||||
port: parsed.port,
|
||||
username: parsed.username,
|
||||
password: parsed.password,
|
||||
}),
|
||||
ProxyParseResult::Ambiguous {
|
||||
possible_formats, ..
|
||||
} => Err(format!(
|
||||
"Ambiguous proxy format. Could be: {}",
|
||||
possible_formats.join(" or ")
|
||||
)),
|
||||
ProxyParseResult::Invalid { reason, .. } => {
|
||||
Err(format!("Failed to parse proxy response: {reason}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export all proxies as JSON
|
||||
pub fn export_proxies_json(&self) -> Result<String, String> {
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
@@ -2095,17 +1993,52 @@ impl ProxyManager {
|
||||
sysinfo::RefreshKind::nothing().with_processes(sysinfo::ProcessRefreshKind::everything()),
|
||||
);
|
||||
|
||||
let dead_browser_entries: Vec<(u32, String, Option<String>)> = snapshot
|
||||
.into_iter()
|
||||
.filter(|(browser_pid, _, _)| {
|
||||
// The sentinel PID=0 is used as a placeholder during launch,
|
||||
// before update_proxy_pid has recorded the real browser PID.
|
||||
*browser_pid != 0
|
||||
&& system
|
||||
.process(sysinfo::Pid::from_u32(*browser_pid))
|
||||
.is_none()
|
||||
})
|
||||
.collect();
|
||||
// Two-state classification: alive PIDs reset their miss counter,
|
||||
// dead PIDs increment it. A worker is only reaped after MISS_THRESHOLD
|
||||
// consecutive misses (~60s by default given the 30s cleanup cadence),
|
||||
// so a single sysinfo blip under heavy load doesn't kill a healthy worker.
|
||||
const MISS_THRESHOLD: u8 = 2;
|
||||
|
||||
let mut alive_pids: Vec<u32> = Vec::new();
|
||||
let mut dead_candidates: Vec<(u32, String, Option<String>)> = Vec::new();
|
||||
let mut snapshot_pids: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
for (browser_pid, proxy_id, profile_id) in snapshot {
|
||||
snapshot_pids.insert(browser_pid);
|
||||
// The sentinel PID=0 is used as a placeholder during launch,
|
||||
// before update_proxy_pid has recorded the real browser PID.
|
||||
if browser_pid == 0 {
|
||||
continue;
|
||||
}
|
||||
if system
|
||||
.process(sysinfo::Pid::from_u32(browser_pid))
|
||||
.is_some()
|
||||
{
|
||||
alive_pids.push(browser_pid);
|
||||
} else {
|
||||
dead_candidates.push((browser_pid, proxy_id, profile_id));
|
||||
}
|
||||
}
|
||||
|
||||
let dead_browser_entries: Vec<(u32, String, Option<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 {
|
||||
log::info!(
|
||||
@@ -2241,8 +2174,6 @@ mod tests {
|
||||
use hyper::Response;
|
||||
use hyper_util::rt::TokioIo;
|
||||
use tokio::net::TcpListener;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
// Helper function to build donut-proxy binary for testing
|
||||
async fn ensure_donut_proxy_binary() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
@@ -3511,263 +3442,4 @@ mod tests {
|
||||
|
||||
delete_proxy_config(&id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_standard_format() {
|
||||
let body = r#"{"ip": "1.2.3.4", "port": 8080, "username": "user1", "password": "pass1"}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.host, "1.2.3.4");
|
||||
assert_eq!(result.port, 8080);
|
||||
assert_eq!(result.proxy_type, "http");
|
||||
assert_eq!(result.username.as_deref(), Some("user1"));
|
||||
assert_eq!(result.password.as_deref(), Some("pass1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_host_alias() {
|
||||
let body = r#"{"host": "proxy.example.com", "port": 3128}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.host, "proxy.example.com");
|
||||
assert_eq!(result.port, 3128);
|
||||
assert!(result.username.is_none());
|
||||
assert!(result.password.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_user_pass_aliases() {
|
||||
let body = r#"{"ip": "10.0.0.1", "port": 1080, "user": "u", "pass": "p"}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.username.as_deref(), Some("u"));
|
||||
assert_eq!(result.password.as_deref(), Some("p"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_port_as_string() {
|
||||
let body = r#"{"ip": "1.2.3.4", "port": "9090"}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.port, 9090);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_with_proxy_type() {
|
||||
let body = r#"{"ip": "1.2.3.4", "port": 1080, "type": "socks5"}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.proxy_type, "socks5");
|
||||
|
||||
let body2 = r#"{"ip": "1.2.3.4", "port": 1080, "proxy_type": "socks4"}"#;
|
||||
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
|
||||
assert_eq!(result2.proxy_type, "socks4");
|
||||
|
||||
// "protocol" field alias
|
||||
let body3 = r#"{"ip": "1.2.3.4", "port": 1080, "protocol": "socks5"}"#;
|
||||
let result3 = ProxyManager::parse_dynamic_proxy_json(body3).unwrap();
|
||||
assert_eq!(result3.proxy_type, "socks5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_normalizes_case() {
|
||||
let body = r#"{"ip": "1.2.3.4", "port": 1080, "type": "SOCKS5"}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.proxy_type, "socks5");
|
||||
|
||||
let body2 = r#"{"ip": "1.2.3.4", "port": 8080, "protocol": "HTTP"}"#;
|
||||
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
|
||||
assert_eq!(result2.proxy_type, "http");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_strips_protocol_from_host() {
|
||||
// User's API returns "ip": "socks5://1.2.3.4" with protocol embedded in host
|
||||
let body = r#"{"ip": "socks5://1.2.3.4", "port": 1080, "username": "u", "password": "p"}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.host, "1.2.3.4");
|
||||
assert_eq!(result.proxy_type, "socks5");
|
||||
assert_eq!(result.port, 1080);
|
||||
|
||||
// Protocol in host should be used as proxy_type when no explicit type field
|
||||
let body2 = r#"{"ip": "http://10.0.0.1", "port": 8080}"#;
|
||||
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
|
||||
assert_eq!(result2.host, "10.0.0.1");
|
||||
assert_eq!(result2.proxy_type, "http");
|
||||
|
||||
// Explicit type field takes precedence over protocol in host
|
||||
let body3 = r#"{"ip": "http://10.0.0.1", "port": 1080, "type": "socks5"}"#;
|
||||
let result3 = ProxyManager::parse_dynamic_proxy_json(body3).unwrap();
|
||||
assert_eq!(result3.host, "10.0.0.1");
|
||||
assert_eq!(result3.proxy_type, "socks5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_empty_credentials_treated_as_none() {
|
||||
let body = r#"{"ip": "1.2.3.4", "port": 8080, "username": "", "password": ""}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert!(result.username.is_none());
|
||||
assert!(result.password.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_missing_ip() {
|
||||
let body = r#"{"port": 8080}"#;
|
||||
let err = ProxyManager::parse_dynamic_proxy_json(body).unwrap_err();
|
||||
assert!(err.contains("ip") || err.contains("host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_missing_port() {
|
||||
let body = r#"{"ip": "1.2.3.4"}"#;
|
||||
let err = ProxyManager::parse_dynamic_proxy_json(body).unwrap_err();
|
||||
assert!(err.contains("port"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_invalid_json() {
|
||||
let err = ProxyManager::parse_dynamic_proxy_json("not json").unwrap_err();
|
||||
assert!(err.contains("Invalid JSON"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_not_object() {
|
||||
let err = ProxyManager::parse_dynamic_proxy_json("[1,2,3]").unwrap_err();
|
||||
assert!(err.contains("not an object"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_text_host_port_user_pass() {
|
||||
let body = "proxy.example.com:8080:user1:pass1";
|
||||
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
|
||||
assert_eq!(result.host, "proxy.example.com");
|
||||
assert_eq!(result.port, 8080);
|
||||
assert_eq!(result.username.as_deref(), Some("user1"));
|
||||
assert_eq!(result.password.as_deref(), Some("pass1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_text_protocol_url_format() {
|
||||
let body = "http://user:pass@proxy.example.com:3128";
|
||||
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
|
||||
assert_eq!(result.host, "proxy.example.com");
|
||||
assert_eq!(result.port, 3128);
|
||||
assert_eq!(result.proxy_type, "http");
|
||||
assert_eq!(result.username.as_deref(), Some("user"));
|
||||
assert_eq!(result.password.as_deref(), Some("pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_text_with_whitespace() {
|
||||
let body = " \n proxy.example.com:8080:user:pass \n ";
|
||||
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
|
||||
assert_eq!(result.host, "proxy.example.com");
|
||||
assert_eq!(result.port, 8080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_text_empty() {
|
||||
let err = ProxyManager::parse_dynamic_proxy_text("").unwrap_err();
|
||||
assert!(err.contains("Empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_text_whitespace_only() {
|
||||
let err = ProxyManager::parse_dynamic_proxy_text(" \n \n ").unwrap_err();
|
||||
assert!(err.contains("Empty"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_proxy_from_url_parses_json_response() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/hook"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(
|
||||
r#"{"host":"proxy.example.com","port":3128,"type":"socks5","username":"user","password":"pass"}"#,
|
||||
),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let pm = ProxyManager::new();
|
||||
let result = pm
|
||||
.fetch_proxy_from_url(
|
||||
&format!("{}/hook", server.uri()),
|
||||
Duration::from_millis(500),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.host, "proxy.example.com");
|
||||
assert_eq!(result.port, 3128);
|
||||
assert_eq!(result.proxy_type, "socks5");
|
||||
assert_eq!(result.username.as_deref(), Some("user"));
|
||||
assert_eq!(result.password.as_deref(), Some("pass"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_proxy_from_url_parses_text_response() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/hook"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string("socks5://user:pass@1.2.3.4:1080"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let pm = ProxyManager::new();
|
||||
let result = pm
|
||||
.fetch_proxy_from_url(
|
||||
&format!("{}/hook", server.uri()),
|
||||
Duration::from_millis(500),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.host, "1.2.3.4");
|
||||
assert_eq!(result.port, 1080);
|
||||
assert_eq!(result.proxy_type, "socks5");
|
||||
assert_eq!(result.username.as_deref(), Some("user"));
|
||||
assert_eq!(result.password.as_deref(), Some("pass"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_proxy_from_url_returns_none_for_no_content() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/hook"))
|
||||
.respond_with(ResponseTemplate::new(204))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let pm = ProxyManager::new();
|
||||
let result = pm
|
||||
.fetch_proxy_from_url(
|
||||
&format!("{}/hook", server.uri()),
|
||||
Duration::from_millis(500),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_proxy_from_url_respects_timeout() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/hook"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_delay(Duration::from_millis(200))
|
||||
.set_body_string(r#"{"host":"1.2.3.4","port":8080}"#),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let pm = ProxyManager::new();
|
||||
let err = pm
|
||||
.fetch_proxy_from_url(&format!("{}/hook", server.uri()), Duration::from_millis(50))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("Failed to fetch launch hook"));
|
||||
}
|
||||
}
|
||||
|
||||
+102
-88
@@ -16,7 +16,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
/// Combined read+write trait for tunnel target streams, allowing
|
||||
@@ -919,8 +918,8 @@ async fn handle_http(
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
log::error!(
|
||||
"DEBUG: Handling HTTP request: {} {} (host: {:?})",
|
||||
log::trace!(
|
||||
"Handling HTTP request: {} {} (host: {:?})",
|
||||
req.method(),
|
||||
req.uri(),
|
||||
req.uri().host()
|
||||
@@ -1183,7 +1182,7 @@ pub async fn handle_proxy_connection(
|
||||
}
|
||||
|
||||
pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::error::Error>> {
|
||||
log::error!(
|
||||
log::info!(
|
||||
"Proxy worker starting, looking for config id: {}",
|
||||
config.id
|
||||
);
|
||||
@@ -1197,7 +1196,7 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
}
|
||||
};
|
||||
|
||||
log::error!(
|
||||
log::info!(
|
||||
"Found config: id={}, port={:?}, upstream={}, profile_id={:?}",
|
||||
config.id,
|
||||
config.local_port,
|
||||
@@ -1205,49 +1204,67 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
config.profile_id
|
||||
);
|
||||
|
||||
log::error!("Starting proxy server for config id: {}", config.id);
|
||||
|
||||
// Initialize traffic tracker with profile ID if available
|
||||
// This can now be called multiple times to update the tracker
|
||||
// Initialize traffic tracker with profile ID if available.
|
||||
// This can be called multiple times to update the tracker.
|
||||
init_traffic_tracker(config.id.clone(), config.profile_id.clone());
|
||||
log::error!(
|
||||
"Traffic tracker initialized for proxy: {} (profile_id: {:?})",
|
||||
config.id,
|
||||
config.profile_id
|
||||
);
|
||||
|
||||
// Verify tracker was initialized correctly
|
||||
if let Some(tracker) = crate::traffic_stats::get_traffic_tracker() {
|
||||
log::error!(
|
||||
"Tracker verified: proxy_id={}, profile_id={:?}",
|
||||
tracker.proxy_id,
|
||||
tracker.profile_id
|
||||
);
|
||||
} else {
|
||||
log::error!("WARNING: Tracker was not initialized!");
|
||||
}
|
||||
|
||||
// Determine the bind address
|
||||
let bind_addr = SocketAddr::from(([127, 0, 0, 1], config.local_port.unwrap_or(0)));
|
||||
|
||||
log::error!("Attempting to bind proxy server to {}", bind_addr);
|
||||
log::info!("Attempting to bind proxy server to {}", bind_addr);
|
||||
|
||||
// Bind to the port
|
||||
let listener = TcpListener::bind(bind_addr).await?;
|
||||
// Bind to the port. Use SO_REUSEADDR so that a freshly-restarted worker
|
||||
// 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();
|
||||
|
||||
log::error!("Successfully bound to port {}", actual_port);
|
||||
log::info!("Successfully bound to port {}", actual_port);
|
||||
|
||||
// Update config with actual port and local_url
|
||||
let mut updated_config = config.clone();
|
||||
updated_config.local_port = Some(actual_port);
|
||||
updated_config.local_url = Some(format!("http://127.0.0.1:{}", actual_port));
|
||||
|
||||
// Save the updated config
|
||||
log::error!(
|
||||
"Saving updated config with local_url={:?}",
|
||||
updated_config.local_url
|
||||
);
|
||||
if !crate::proxy_storage::update_proxy_config(&updated_config) {
|
||||
log::error!("Failed to update proxy config");
|
||||
return Err("Failed to update proxy config".into());
|
||||
@@ -1259,12 +1276,11 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
Some(updated_config.upstream_url.clone())
|
||||
};
|
||||
|
||||
log::error!("Proxy server bound to 127.0.0.1:{}", actual_port);
|
||||
log::error!(
|
||||
log::info!(
|
||||
"Proxy server listening on 127.0.0.1:{} (ready to accept connections)",
|
||||
actual_port
|
||||
);
|
||||
log::error!("Proxy server entering accept loop - process should stay alive");
|
||||
log::info!("Proxy server entering accept loop - process should stay alive");
|
||||
|
||||
// Start a background task to write lightweight session snapshots for real-time updates
|
||||
// These are much smaller than full stats and can be written frequently (~100 bytes every 2 seconds)
|
||||
@@ -1295,52 +1311,54 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
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;
|
||||
// Catch panics so a poisoned lock or unexpected error inside
|
||||
// flush_to_disk doesn't abort the flush task and leave stats
|
||||
// unwritten for the lifetime of the worker. The captured state
|
||||
// is all Copy or atomic-assignment, so AssertUnwindSafe is sound.
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
if let Some(tracker) = get_traffic_tracker() {
|
||||
let (sent, recv, requests) = tracker.get_snapshot();
|
||||
let current_bytes = sent + recv;
|
||||
let time_since_activity = last_activity_time.elapsed();
|
||||
let time_since_flush = last_flush_time.elapsed();
|
||||
let has_traffic = current_bytes > 0 || requests > 0;
|
||||
|
||||
// Determine flush frequency based on activity
|
||||
// When active: flush every 5 seconds
|
||||
// When idle: flush every 30 seconds
|
||||
let desired_interval_secs =
|
||||
if has_traffic || time_since_activity < std::time::Duration::from_secs(30) {
|
||||
5u64
|
||||
} else {
|
||||
30u64
|
||||
};
|
||||
let desired_interval_secs =
|
||||
if has_traffic || time_since_activity < std::time::Duration::from_secs(30) {
|
||||
5u64
|
||||
} else {
|
||||
30u64
|
||||
};
|
||||
|
||||
// Update interval if needed
|
||||
if desired_interval_secs != current_interval_secs {
|
||||
current_interval_secs = desired_interval_secs;
|
||||
interval = tokio::time::interval(tokio::time::Duration::from_secs(desired_interval_secs));
|
||||
}
|
||||
if desired_interval_secs != current_interval_secs {
|
||||
current_interval_secs = desired_interval_secs;
|
||||
interval =
|
||||
tokio::time::interval(tokio::time::Duration::from_secs(desired_interval_secs));
|
||||
}
|
||||
|
||||
// Only flush if enough time has passed since last flush
|
||||
let flush_interval = std::time::Duration::from_secs(desired_interval_secs);
|
||||
let should_flush = time_since_flush >= flush_interval;
|
||||
let flush_interval = std::time::Duration::from_secs(desired_interval_secs);
|
||||
let should_flush = time_since_flush >= flush_interval;
|
||||
|
||||
if should_flush {
|
||||
match tracker.flush_to_disk() {
|
||||
Ok(Some((sent, recv))) => {
|
||||
// Successful flush with data
|
||||
last_flush_time = std::time::Instant::now();
|
||||
if sent > 0 || recv > 0 {
|
||||
last_activity_time = std::time::Instant::now();
|
||||
if should_flush {
|
||||
match tracker.flush_to_disk() {
|
||||
Ok(Some((sent, recv))) => {
|
||||
last_flush_time = std::time::Instant::now();
|
||||
if sent > 0 || recv > 0 {
|
||||
last_activity_time = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
last_flush_time = std::time::Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to flush traffic stats: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// No data to flush - this is normal
|
||||
last_flush_time = std::time::Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to flush traffic stats: {}", e);
|
||||
// Don't update flush time on error - retry sooner
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
if let Err(panic) = result {
|
||||
log::error!("Panic caught in proxy traffic flush task; continuing: {panic:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1581,7 +1599,7 @@ async fn handle_connect_from_buffer(
|
||||
.await?;
|
||||
client_stream.flush().await?;
|
||||
|
||||
log::error!("DEBUG: Sent 200 Connection Established response, starting tunnel");
|
||||
log::trace!("Sent 200 Connection Established response, starting tunnel");
|
||||
|
||||
// Now tunnel data bidirectionally with counting
|
||||
// Wrap streams to count bytes transferred
|
||||
@@ -1598,17 +1616,17 @@ async fn handle_connect_from_buffer(
|
||||
let (mut client_read, mut client_write) = tokio::io::split(counting_client);
|
||||
let (mut target_read, mut target_write) = tokio::io::split(counting_target);
|
||||
|
||||
log::error!("DEBUG: Starting bidirectional tunnel");
|
||||
log::trace!("Starting bidirectional tunnel");
|
||||
|
||||
// Spawn two tasks to forward data in both directions
|
||||
let client_to_target = tokio::spawn(async move {
|
||||
let result = tokio::io::copy(&mut client_read, &mut target_write).await;
|
||||
match result {
|
||||
Ok(bytes) => {
|
||||
log::error!("DEBUG: Tunneled {} bytes from client->target", bytes);
|
||||
log::trace!("Tunneled {bytes} bytes from client->target");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error forwarding client->target: {:?}", e);
|
||||
log::debug!("Error forwarding client->target: {e:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1617,10 +1635,10 @@ async fn handle_connect_from_buffer(
|
||||
let result = tokio::io::copy(&mut target_read, &mut client_write).await;
|
||||
match result {
|
||||
Ok(bytes) => {
|
||||
log::error!("DEBUG: Tunneled {} bytes from target->client", bytes);
|
||||
log::trace!("Tunneled {bytes} bytes from target->client");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error forwarding target->client: {:?}", e);
|
||||
log::debug!("Error forwarding target->client: {e:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1628,10 +1646,10 @@ async fn handle_connect_from_buffer(
|
||||
// Wait for either direction to finish (connection closed)
|
||||
tokio::select! {
|
||||
_ = client_to_target => {
|
||||
log::error!("DEBUG: Client->target tunnel closed");
|
||||
log::trace!("Client->target tunnel closed");
|
||||
}
|
||||
_ = target_to_client => {
|
||||
log::error!("DEBUG: Target->client tunnel closed");
|
||||
log::trace!("Target->client tunnel closed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1640,11 +1658,7 @@ async fn handle_connect_from_buffer(
|
||||
client_read_counter.load(Ordering::Relaxed) + target_write_counter.load(Ordering::Relaxed);
|
||||
let final_recv =
|
||||
target_read_counter.load(Ordering::Relaxed) + client_write_counter.load(Ordering::Relaxed);
|
||||
log::error!(
|
||||
"DEBUG: Tunnel closed - sent: {} bytes, received: {} bytes",
|
||||
final_sent,
|
||||
final_recv
|
||||
);
|
||||
log::trace!("Tunnel closed - sent: {final_sent} bytes, received: {final_recv} bytes");
|
||||
|
||||
// Update domain-specific byte counts now that tunnel is complete
|
||||
if let Some(tracker) = get_traffic_tracker() {
|
||||
|
||||
@@ -57,6 +57,11 @@ pub struct AppSettings {
|
||||
pub window_resize_warning_dismissed: bool,
|
||||
#[serde(default)]
|
||||
pub disable_auto_updates: bool,
|
||||
/// When true, the decrypted in-RAM copy of a password-protected profile is
|
||||
/// preserved between launches for faster subsequent startups. The on-disk
|
||||
/// copy is always re-encrypted regardless of this flag.
|
||||
#[serde(default)]
|
||||
pub keep_decrypted_profiles_in_ram: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
@@ -92,6 +97,7 @@ impl Default for AppSettings {
|
||||
language: None,
|
||||
window_resize_warning_dismissed: false,
|
||||
disable_auto_updates: false,
|
||||
keep_decrypted_profiles_in_ram: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -814,6 +820,105 @@ pub async fn save_app_settings(
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
/// Read the most recent N log files concatenated into a single string,
|
||||
/// suitable for paste-into-issue-tracker. Newest entries appear LAST so the
|
||||
/// reader sees fresh context at the bottom of the buffer. Capped at 5 MB to
|
||||
/// keep clipboard payloads sane.
|
||||
#[tauri::command]
|
||||
pub async fn read_log_files(app_handle: tauri::AppHandle) -> Result<String, String> {
|
||||
let dir = crate::app_dirs::log_dir(&app_handle);
|
||||
if !dir.exists() {
|
||||
return Err("Log directory does not exist yet".to_string());
|
||||
}
|
||||
|
||||
let mut entries: Vec<(std::path::PathBuf, std::time::SystemTime)> = std::fs::read_dir(&dir)
|
||||
.map_err(|e| format!("Failed to read log dir: {e}"))?
|
||||
.filter_map(|r| r.ok())
|
||||
.filter_map(|e| {
|
||||
let p = e.path();
|
||||
let m = e.metadata().ok()?.modified().ok()?;
|
||||
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
if p.is_file() && (ext == "log" || ext == "txt") {
|
||||
Some((p, m))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
entries.sort_by_key(|(_, m)| *m);
|
||||
|
||||
const MAX_BYTES: usize = 5 * 1024 * 1024;
|
||||
let mut out = String::with_capacity(64 * 1024);
|
||||
for (path, _) in entries.iter().rev() {
|
||||
let header = format!("===== {} =====\n", path.display());
|
||||
if out.len() + header.len() >= MAX_BYTES {
|
||||
break;
|
||||
}
|
||||
out.push_str(&header);
|
||||
if let Ok(content) = std::fs::read_to_string(path) {
|
||||
let take = MAX_BYTES.saturating_sub(out.len());
|
||||
if take == 0 {
|
||||
break;
|
||||
}
|
||||
if content.len() > take {
|
||||
// Tail truncation — keep the END of older files so newest data is preserved.
|
||||
out.push_str("[…truncated — older content elided…]\n");
|
||||
out.push_str(&content[content.len() - take + 64..]);
|
||||
} else {
|
||||
out.push_str(&content);
|
||||
}
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse the per-file order so chronological newest is at the bottom.
|
||||
// (We pushed newest-first above to budget the tail; flip now.)
|
||||
let mut sections: Vec<&str> = out.split("===== ").filter(|s| !s.is_empty()).collect();
|
||||
sections.reverse();
|
||||
let final_out = sections
|
||||
.into_iter()
|
||||
.map(|s| format!("===== {s}"))
|
||||
.collect::<String>();
|
||||
|
||||
Ok(final_out)
|
||||
}
|
||||
|
||||
/// Reveal the log directory in the OS file manager.
|
||||
#[tauri::command]
|
||||
pub async fn open_log_directory(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
let dir = crate::app_dirs::log_dir(&app_handle);
|
||||
if !dir.exists() {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create log dir: {e}"))?;
|
||||
}
|
||||
let path = dir.to_string_lossy().to_string();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open log dir: {e}"))?;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("explorer")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open log dir: {e}"))?;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open log dir: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn should_show_launch_on_login_prompt() -> Result<bool, String> {
|
||||
let manager = SettingsManager::instance();
|
||||
@@ -886,6 +991,17 @@ pub async fn save_sync_settings(
|
||||
sync_server_url: Option<String>,
|
||||
sync_token: Option<String>,
|
||||
) -> Result<SyncSettings, String> {
|
||||
// Cloud login and self-hosted sync share the same sync engine and a
|
||||
// profile can't be sync'd to two backends at once. Block any *write*
|
||||
// (non-null URL or token) while the user is signed into their cloud
|
||||
// account — the clearing path (both `None`) is always allowed so logged-
|
||||
// in users can wipe a stale self-hosted config that pre-dates their
|
||||
// sign-in.
|
||||
let is_setting_self_hosted = sync_server_url.is_some() || sync_token.is_some();
|
||||
if is_setting_self_hosted && crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
|
||||
return Err(serde_json::json!({ "code": "SELF_HOSTED_REQUIRES_LOGOUT" }).to_string());
|
||||
}
|
||||
|
||||
let manager = SettingsManager::instance();
|
||||
|
||||
manager
|
||||
@@ -1070,6 +1186,7 @@ mod tests {
|
||||
language: None,
|
||||
window_resize_warning_dismissed: false,
|
||||
disable_auto_updates: false,
|
||||
keep_decrypted_profiles_in_ram: false,
|
||||
};
|
||||
|
||||
let save_result = manager.save_settings(&test_settings);
|
||||
|
||||
@@ -4,10 +4,40 @@ use aes_gcm::{
|
||||
};
|
||||
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const E2E_FILE_HEADER: &[u8] = b"DBE2E";
|
||||
const E2E_FILE_VERSION: u8 = 1;
|
||||
|
||||
/// Argon2id is intentionally expensive (~80–150 ms per call). During an
|
||||
/// encryption rollover, every synced entity (proxy, group, vpn, extension,
|
||||
/// extension group, profile metadata) goes through `derive_profile_key`,
|
||||
/// which without caching means hundreds of sequential 100 ms derivations.
|
||||
///
|
||||
/// Cache the derived key keyed on (sha256(password), salt). Entries are
|
||||
/// evicted on `set_e2e_password` / `delete_e2e_password` so a password
|
||||
/// change cannot use stale keys.
|
||||
type DerivedKeyCache = HashMap<([u8; 32], String), [u8; 32]>;
|
||||
static KEY_CACHE: std::sync::LazyLock<Mutex<DerivedKeyCache>> =
|
||||
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
fn password_fingerprint(pwd: &str) -> [u8; 32] {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(pwd.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
let mut out = [0u8; 32];
|
||||
out.copy_from_slice(&result);
|
||||
out
|
||||
}
|
||||
|
||||
fn invalidate_key_cache() {
|
||||
if let Ok(mut cache) = KEY_CACHE.lock() {
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn get_e2e_password_path() -> std::path::PathBuf {
|
||||
crate::app_dirs::settings_dir().join("e2e_password.dat")
|
||||
}
|
||||
@@ -17,6 +47,7 @@ fn get_vault_password() -> String {
|
||||
}
|
||||
|
||||
pub fn store_e2e_password(password: &str) -> Result<(), String> {
|
||||
invalidate_key_cache();
|
||||
let file_path = get_e2e_password_path();
|
||||
|
||||
if let Some(parent) = file_path.parent() {
|
||||
@@ -149,6 +180,7 @@ pub fn has_e2e_password() -> bool {
|
||||
}
|
||||
|
||||
pub fn remove_e2e_password() -> Result<(), String> {
|
||||
invalidate_key_cache();
|
||||
let file_path = get_e2e_password_path();
|
||||
if file_path.exists() {
|
||||
std::fs::remove_file(&file_path)
|
||||
@@ -157,8 +189,20 @@ pub fn remove_e2e_password() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Derive a per-profile encryption key using Argon2id
|
||||
/// Derive a per-profile encryption key using Argon2id, with an in-process
|
||||
/// cache keyed on `(sha256(password), salt)`. Repeated calls with the same
|
||||
/// password+salt are O(1); a password change calls `invalidate_key_cache`
|
||||
/// to drop stale entries.
|
||||
pub fn derive_profile_key(user_password: &str, profile_salt: &str) -> Result<[u8; 32], String> {
|
||||
let pwd_fp = password_fingerprint(user_password);
|
||||
let cache_key = (pwd_fp, profile_salt.to_string());
|
||||
|
||||
if let Ok(cache) = KEY_CACHE.lock() {
|
||||
if let Some(cached) = cache.get(&cache_key) {
|
||||
return Ok(*cached);
|
||||
}
|
||||
}
|
||||
|
||||
let salt_bytes = BASE64
|
||||
.decode(profile_salt)
|
||||
.map_err(|e| format!("Invalid salt encoding: {e}"))?;
|
||||
@@ -175,6 +219,11 @@ pub fn derive_profile_key(user_password: &str, profile_salt: &str) -> Result<[u8
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&hash_bytes[..32]);
|
||||
|
||||
if let Ok(mut cache) = KEY_CACHE.lock() {
|
||||
cache.insert(cache_key, key);
|
||||
}
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
@@ -220,13 +269,75 @@ pub fn decrypt_bytes(key: &[u8; 32], encrypted: &[u8]) -> Result<Vec<u8>, String
|
||||
.map_err(|e| format!("Decryption failed: {e}"))
|
||||
}
|
||||
|
||||
/// Versioned encryption envelope used for non-profile entities (proxies,
|
||||
/// VPNs, groups, extensions, extension groups). Each upload has its own
|
||||
/// random per-entity salt so the bucket can't be rainbow-table-attacked
|
||||
/// even with a shared password across many entities.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct EncryptedEnvelope {
|
||||
/// Format version. Increment when changing how `ct` is structured.
|
||||
pub v: u32,
|
||||
/// Base64 of the per-entity salt. Plaintext on the wire — salts are public.
|
||||
pub salt: String,
|
||||
/// Base64 of `nonce(12B) || AES-256-GCM ciphertext` (output of `encrypt_bytes`).
|
||||
pub ct: String,
|
||||
}
|
||||
|
||||
/// Wrap a plaintext JSON byte slice into an encrypted envelope if the user
|
||||
/// has E2E enabled. Returns `(payload_bytes, content_type)` ready to upload.
|
||||
/// On no-password, returns the original JSON unchanged.
|
||||
pub fn maybe_seal_for_upload(json: &[u8]) -> Result<(Vec<u8>, &'static str), String> {
|
||||
let pwd = match load_e2e_password()? {
|
||||
Some(p) => p,
|
||||
None => return Ok((json.to_vec(), "application/json")),
|
||||
};
|
||||
let salt = generate_salt();
|
||||
let key = derive_profile_key(&pwd, &salt)?;
|
||||
let ct = encrypt_bytes(&key, json)?;
|
||||
let envelope = EncryptedEnvelope {
|
||||
v: 1,
|
||||
salt,
|
||||
ct: BASE64.encode(&ct),
|
||||
};
|
||||
let payload =
|
||||
serde_json::to_vec(&envelope).map_err(|e| format!("Failed to serialize envelope: {e}"))?;
|
||||
Ok((payload, "application/json"))
|
||||
}
|
||||
|
||||
/// Reverse of `maybe_seal_for_upload`. Returns the inner plaintext JSON
|
||||
/// bytes regardless of whether `raw` was an envelope or legacy plaintext.
|
||||
///
|
||||
/// Distinguishes three cases:
|
||||
/// - `raw` is plaintext JSON, no password set → returns `raw` unchanged.
|
||||
/// - `raw` is an envelope, password set → decrypts and returns plaintext.
|
||||
/// - `raw` is an envelope, no password set → returns `Err(EncryptedEnvelope)`
|
||||
/// so callers (subscription / startup probe) can show "enter password to
|
||||
/// continue syncing" UI.
|
||||
pub fn maybe_unseal_after_download(raw: &[u8]) -> Result<Vec<u8>, String> {
|
||||
// Try parsing as envelope first; envelopes are JSON objects with a "v" field.
|
||||
if let Ok(env) = serde_json::from_slice::<EncryptedEnvelope>(raw) {
|
||||
if env.v != 1 {
|
||||
return Err(format!("Unsupported envelope version: {}", env.v));
|
||||
}
|
||||
let pwd = load_e2e_password()?.ok_or_else(|| "ENCRYPTION_PASSWORD_REQUIRED".to_string())?;
|
||||
let key = derive_profile_key(&pwd, &env.salt)?;
|
||||
let ct = BASE64
|
||||
.decode(&env.ct)
|
||||
.map_err(|e| format!("Invalid envelope ciphertext: {e}"))?;
|
||||
return decrypt_bytes(&key, &ct);
|
||||
}
|
||||
// Not an envelope — legacy plaintext. Caller will JSON-parse it directly.
|
||||
Ok(raw.to_vec())
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_e2e_password(password: String) -> Result<(), String> {
|
||||
pub async fn set_e2e_password(password: String) -> Result<(), String> {
|
||||
if password.len() < 8 {
|
||||
return Err("Password must be at least 8 characters".to_string());
|
||||
}
|
||||
enforce_team_owner_for_encryption_change().await?;
|
||||
store_e2e_password(&password)
|
||||
}
|
||||
|
||||
@@ -236,10 +347,23 @@ pub fn check_has_e2e_password() -> bool {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_e2e_password() -> Result<(), String> {
|
||||
pub async fn delete_e2e_password() -> Result<(), String> {
|
||||
enforce_team_owner_for_encryption_change().await?;
|
||||
remove_e2e_password()
|
||||
}
|
||||
|
||||
/// On Team plans, only the team owner is allowed to flip the E2E password
|
||||
/// state — otherwise members could lock each other out by changing the key.
|
||||
async fn enforce_team_owner_for_encryption_change() -> Result<(), String> {
|
||||
use crate::cloud_auth::CLOUD_AUTH;
|
||||
if let Some(state) = CLOUD_AUTH.get_user().await {
|
||||
if state.user.plan == "team" && state.user.team_role.as_deref() != Some("owner") {
|
||||
return Err("TEAM_OWNER_ONLY".to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+370
-63
@@ -716,7 +716,9 @@ impl SyncEngine {
|
||||
}
|
||||
|
||||
let presign = self.client.presign_download(key).await?;
|
||||
let data = self.client.download_bytes(&presign.url).await?;
|
||||
let raw = self.client.download_bytes(&presign.url).await?;
|
||||
let data = encryption::maybe_unseal_after_download(&raw)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal profile metadata: {e}")))?;
|
||||
let profile: BrowserProfile = serde_json::from_slice(&data)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to parse metadata: {e}")))?;
|
||||
|
||||
@@ -794,15 +796,18 @@ impl SyncEngine {
|
||||
let json = serde_json::to_string_pretty(&sanitized)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize profile: {e}")))?;
|
||||
|
||||
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal profile metadata: {e}")))?;
|
||||
|
||||
let remote_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
|
||||
let presign = self
|
||||
.client
|
||||
.presign_upload(&remote_key, Some("application/json"))
|
||||
.presign_upload(&remote_key, Some(content_type))
|
||||
.await?;
|
||||
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
|
||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
@@ -1392,17 +1397,20 @@ impl SyncEngine {
|
||||
let json = serde_json::to_string_pretty(&updated_proxy)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize proxy: {e}")))?;
|
||||
|
||||
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal proxy: {e}")))?;
|
||||
|
||||
let remote_key = format!("proxies/{}.json", proxy.id);
|
||||
let presign = self
|
||||
.client
|
||||
.presign_upload(&remote_key, Some("application/json"))
|
||||
.presign_upload(&remote_key, Some(content_type))
|
||||
.await?;
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
|
||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
||||
.await?;
|
||||
|
||||
// Update local proxy with new last_sync
|
||||
// Update local proxy with new last_sync (always write plaintext locally)
|
||||
let proxy_manager = &crate::proxy_manager::PROXY_MANAGER;
|
||||
let proxy_file = proxy_manager.get_proxy_file_path(&proxy.id);
|
||||
fs::write(&proxy_file, &json).map_err(|e| {
|
||||
@@ -1423,7 +1431,10 @@ impl SyncEngine {
|
||||
) -> SyncResult<()> {
|
||||
let remote_key = format!("proxies/{}.json", proxy_id);
|
||||
let presign = self.client.presign_download(&remote_key).await?;
|
||||
let data = self.client.download_bytes(&presign.url).await?;
|
||||
let raw = self.client.download_bytes(&presign.url).await?;
|
||||
|
||||
let data = encryption::maybe_unseal_after_download(&raw)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal proxy: {e}")))?;
|
||||
|
||||
let mut proxy: crate::proxy_manager::StoredProxy = serde_json::from_slice(&data)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to parse proxy JSON: {e}")))?;
|
||||
@@ -1534,14 +1545,17 @@ impl SyncEngine {
|
||||
let json = serde_json::to_string_pretty(&updated_group)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize group: {e}")))?;
|
||||
|
||||
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal group: {e}")))?;
|
||||
|
||||
let remote_key = format!("groups/{}.json", group.id);
|
||||
let presign = self
|
||||
.client
|
||||
.presign_upload(&remote_key, Some("application/json"))
|
||||
.presign_upload(&remote_key, Some(content_type))
|
||||
.await?;
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
|
||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
||||
.await?;
|
||||
|
||||
// Update local group with new last_sync
|
||||
@@ -1563,7 +1577,10 @@ impl SyncEngine {
|
||||
) -> SyncResult<()> {
|
||||
let remote_key = format!("groups/{}.json", group_id);
|
||||
let presign = self.client.presign_download(&remote_key).await?;
|
||||
let data = self.client.download_bytes(&presign.url).await?;
|
||||
let raw = self.client.download_bytes(&presign.url).await?;
|
||||
|
||||
let data = encryption::maybe_unseal_after_download(&raw)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal group: {e}")))?;
|
||||
|
||||
let mut group: crate::group_manager::ProfileGroup = serde_json::from_slice(&data)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to parse group JSON: {e}")))?;
|
||||
@@ -1738,14 +1755,17 @@ impl SyncEngine {
|
||||
let json = serde_json::to_string_pretty(&updated_vpn)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize VPN: {e}")))?;
|
||||
|
||||
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal VPN: {e}")))?;
|
||||
|
||||
let remote_key = format!("vpns/{}.json", vpn.id);
|
||||
let presign = self
|
||||
.client
|
||||
.presign_upload(&remote_key, Some("application/json"))
|
||||
.presign_upload(&remote_key, Some(content_type))
|
||||
.await?;
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
|
||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
||||
.await?;
|
||||
|
||||
// Update local VPN with new last_sync
|
||||
@@ -1767,7 +1787,10 @@ impl SyncEngine {
|
||||
) -> SyncResult<()> {
|
||||
let remote_key = format!("vpns/{}.json", vpn_id);
|
||||
let presign = self.client.presign_download(&remote_key).await?;
|
||||
let data = self.client.download_bytes(&presign.url).await?;
|
||||
let raw = self.client.download_bytes(&presign.url).await?;
|
||||
|
||||
let data = encryption::maybe_unseal_after_download(&raw)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal VPN: {e}")))?;
|
||||
|
||||
let mut vpn: crate::vpn::VpnConfig = serde_json::from_slice(&data)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to parse VPN JSON: {e}")))?;
|
||||
@@ -1883,17 +1906,21 @@ impl SyncEngine {
|
||||
let json = serde_json::to_string_pretty(&updated_ext)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize extension: {e}")))?;
|
||||
|
||||
let (meta_payload, meta_content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension: {e}")))?;
|
||||
|
||||
let remote_key = format!("extensions/{}.json", ext.id);
|
||||
let presign = self
|
||||
.client
|
||||
.presign_upload(&remote_key, Some("application/json"))
|
||||
.presign_upload(&remote_key, Some(meta_content_type))
|
||||
.await?;
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
|
||||
.upload_bytes(&presign.url, &meta_payload, Some(meta_content_type))
|
||||
.await?;
|
||||
|
||||
// Also upload the extension file data
|
||||
// Also upload the extension file data — encrypted as a sealed envelope
|
||||
// when E2E is on (the binary is the secret here, not just the metadata).
|
||||
let file_path = {
|
||||
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let file_dir = manager.get_file_dir_public(&ext.id);
|
||||
@@ -1908,18 +1935,17 @@ impl SyncEngine {
|
||||
))
|
||||
})?;
|
||||
|
||||
let (file_payload, file_content_type) = encryption::maybe_seal_for_upload(&file_data)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension file: {e}")))?;
|
||||
|
||||
let file_remote_key = format!("extensions/{}/file/{}", ext.id, ext.file_name);
|
||||
let file_presign = self
|
||||
.client
|
||||
.presign_upload(&file_remote_key, Some("application/octet-stream"))
|
||||
.presign_upload(&file_remote_key, Some(file_content_type))
|
||||
.await?;
|
||||
self
|
||||
.client
|
||||
.upload_bytes(
|
||||
&file_presign.url,
|
||||
&file_data,
|
||||
Some("application/octet-stream"),
|
||||
)
|
||||
.upload_bytes(&file_presign.url, &file_payload, Some(file_content_type))
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -1942,7 +1968,9 @@ impl SyncEngine {
|
||||
) -> SyncResult<()> {
|
||||
let remote_key = format!("extensions/{}.json", ext_id);
|
||||
let presign = self.client.presign_download(&remote_key).await?;
|
||||
let data = self.client.download_bytes(&presign.url).await?;
|
||||
let raw = self.client.download_bytes(&presign.url).await?;
|
||||
let data = encryption::maybe_unseal_after_download(&raw)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal extension: {e}")))?;
|
||||
|
||||
let mut ext: crate::extension_manager::Extension = serde_json::from_slice(&data)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to parse extension JSON: {e}")))?;
|
||||
@@ -1960,7 +1988,9 @@ impl SyncEngine {
|
||||
let file_stat = self.client.stat(&file_remote_key).await?;
|
||||
if file_stat.exists {
|
||||
let file_presign = self.client.presign_download(&file_remote_key).await?;
|
||||
let file_data = self.client.download_bytes(&file_presign.url).await?;
|
||||
let file_raw = self.client.download_bytes(&file_presign.url).await?;
|
||||
let file_data = encryption::maybe_unseal_after_download(&file_raw)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal extension file: {e}")))?;
|
||||
|
||||
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let file_dir = manager.get_file_dir_public(&ext.id);
|
||||
@@ -2085,14 +2115,17 @@ impl SyncEngine {
|
||||
SyncError::SerializationError(format!("Failed to serialize extension group: {e}"))
|
||||
})?;
|
||||
|
||||
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal extension group: {e}")))?;
|
||||
|
||||
let remote_key = format!("extension_groups/{}.json", group.id);
|
||||
let presign = self
|
||||
.client
|
||||
.presign_upload(&remote_key, Some("application/json"))
|
||||
.presign_upload(&remote_key, Some(content_type))
|
||||
.await?;
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
|
||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
||||
.await?;
|
||||
|
||||
// Update local group with new last_sync
|
||||
@@ -2114,7 +2147,10 @@ impl SyncEngine {
|
||||
) -> SyncResult<()> {
|
||||
let remote_key = format!("extension_groups/{}.json", group_id);
|
||||
let presign = self.client.presign_download(&remote_key).await?;
|
||||
let data = self.client.download_bytes(&presign.url).await?;
|
||||
let raw = self.client.download_bytes(&presign.url).await?;
|
||||
|
||||
let data = encryption::maybe_unseal_after_download(&raw)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to unseal extension group: {e}")))?;
|
||||
|
||||
let mut group: crate::extension_manager::ExtensionGroup = serde_json::from_slice(&data)
|
||||
.map_err(|e| {
|
||||
@@ -2742,10 +2778,7 @@ pub fn is_group_used_by_synced_profile(group_id: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Enable sync for proxy if not already enabled
|
||||
pub async fn enable_proxy_sync_if_needed(
|
||||
proxy_id: &str,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
pub async fn enable_proxy_sync_if_needed(proxy_id: &str) -> Result<(), String> {
|
||||
let proxy_manager = &crate::proxy_manager::PROXY_MANAGER;
|
||||
let proxies = proxy_manager.get_stored_proxies();
|
||||
let proxy = proxies
|
||||
@@ -2754,15 +2787,7 @@ pub async fn enable_proxy_sync_if_needed(
|
||||
.ok_or_else(|| format!("Proxy with ID '{proxy_id}' not found"))?;
|
||||
|
||||
if !proxy.sync_enabled {
|
||||
let mut updated_proxy = proxy.clone();
|
||||
updated_proxy.sync_enabled = true;
|
||||
|
||||
let proxy_file = proxy_manager.get_proxy_file_path(&proxy.id);
|
||||
let json = serde_json::to_string_pretty(&updated_proxy)
|
||||
.map_err(|e| format!("Failed to serialize proxy: {e}"))?;
|
||||
std::fs::write(&proxy_file, &json)
|
||||
.map_err(|e| format!("Failed to update proxy file {}: {e}", proxy_file.display()))?;
|
||||
|
||||
proxy_manager.set_stored_proxy_sync_state(proxy_id, true, proxy.last_sync)?;
|
||||
let _ = events::emit("stored-proxies-changed", ());
|
||||
log::info!("Auto-enabled sync for proxy {}", proxy_id);
|
||||
}
|
||||
@@ -2783,10 +2808,7 @@ pub fn is_vpn_used_by_synced_profile(vpn_id: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Enable sync for VPN if not already enabled
|
||||
pub async fn enable_vpn_sync_if_needed(
|
||||
vpn_id: &str,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
pub async fn enable_vpn_sync_if_needed(vpn_id: &str) -> Result<(), String> {
|
||||
let vpn = {
|
||||
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
|
||||
storage
|
||||
@@ -2808,10 +2830,7 @@ pub async fn enable_vpn_sync_if_needed(
|
||||
}
|
||||
|
||||
/// Enable sync for group if not already enabled
|
||||
pub async fn enable_group_sync_if_needed(
|
||||
group_id: &str,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
pub async fn enable_group_sync_if_needed(group_id: &str) -> Result<(), String> {
|
||||
let group = {
|
||||
let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap();
|
||||
let groups = group_manager.get_all_groups().unwrap_or_default();
|
||||
@@ -2840,6 +2859,66 @@ pub async fn enable_group_sync_if_needed(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enable sync for extension group (and its member extensions) if not
|
||||
/// already enabled. Mirrors the proxy/vpn/group helpers — call from any
|
||||
/// site where a synced profile gains an `extension_group_id`.
|
||||
pub async fn enable_extension_group_sync_if_needed(extension_group_id: &str) -> Result<(), String> {
|
||||
let (group_already_synced, extension_ids) = {
|
||||
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let group = manager
|
||||
.get_group(extension_group_id)
|
||||
.map_err(|e| format!("Extension group with ID '{extension_group_id}' not found: {e}"))?;
|
||||
(group.sync_enabled, group.extension_ids.clone())
|
||||
};
|
||||
|
||||
if !group_already_synced {
|
||||
let mut updated_group = {
|
||||
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
manager
|
||||
.get_group(extension_group_id)
|
||||
.map_err(|e| format!("Failed to load extension group: {e}"))?
|
||||
};
|
||||
updated_group.sync_enabled = true;
|
||||
{
|
||||
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
manager
|
||||
.update_group_internal(&updated_group)
|
||||
.map_err(|e| format!("Failed to update extension group sync: {e}"))?;
|
||||
}
|
||||
let _ = events::emit("extensions-changed", ());
|
||||
log::info!(
|
||||
"Auto-enabled sync for extension group {}",
|
||||
extension_group_id
|
||||
);
|
||||
}
|
||||
|
||||
// Cascade to every extension referenced by the group so the other device
|
||||
// has the actual extension binaries when it pulls the group.
|
||||
for ext_id in extension_ids {
|
||||
let already_synced = {
|
||||
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
manager
|
||||
.get_extension(&ext_id)
|
||||
.ok()
|
||||
.map(|e| e.sync_enabled)
|
||||
.unwrap_or(true)
|
||||
};
|
||||
if !already_synced {
|
||||
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
if let Ok(mut ext) = manager.get_extension(&ext_id) {
|
||||
ext.sync_enabled = true;
|
||||
if let Err(e) = manager.update_extension_internal(&ext) {
|
||||
log::warn!("Failed to auto-enable sync for extension {}: {e}", ext_id);
|
||||
} else {
|
||||
log::info!("Auto-enabled sync for extension {}", ext_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_profile_sync_mode(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -2968,26 +3047,39 @@ pub async fn set_profile_sync_mode(
|
||||
.await;
|
||||
|
||||
if let Some(ref proxy_id) = profile.proxy_id {
|
||||
if let Err(e) = enable_proxy_sync_if_needed(proxy_id, &app_handle).await {
|
||||
if let Err(e) = enable_proxy_sync_if_needed(proxy_id).await {
|
||||
log::warn!("Failed to enable sync for proxy {}: {}", proxy_id, e);
|
||||
} else {
|
||||
scheduler.queue_proxy_sync(proxy_id.clone()).await;
|
||||
}
|
||||
}
|
||||
if let Some(ref group_id) = profile.group_id {
|
||||
if let Err(e) = enable_group_sync_if_needed(group_id, &app_handle).await {
|
||||
if let Err(e) = enable_group_sync_if_needed(group_id).await {
|
||||
log::warn!("Failed to enable sync for group {}: {}", group_id, e);
|
||||
} else {
|
||||
scheduler.queue_group_sync(group_id.clone()).await;
|
||||
}
|
||||
}
|
||||
if let Some(ref vpn_id) = profile.vpn_id {
|
||||
if let Err(e) = enable_vpn_sync_if_needed(vpn_id, &app_handle).await {
|
||||
if let Err(e) = enable_vpn_sync_if_needed(vpn_id).await {
|
||||
log::warn!("Failed to enable sync for VPN {}: {}", vpn_id, e);
|
||||
} else {
|
||||
scheduler.queue_vpn_sync(vpn_id.clone()).await;
|
||||
}
|
||||
}
|
||||
if let Some(ref ext_group_id) = profile.extension_group_id {
|
||||
if let Err(e) = enable_extension_group_sync_if_needed(ext_group_id).await {
|
||||
log::warn!(
|
||||
"Failed to enable sync for extension group {}: {}",
|
||||
ext_group_id,
|
||||
e
|
||||
);
|
||||
} else {
|
||||
scheduler
|
||||
.queue_extension_group_sync(ext_group_id.clone())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::warn!("Scheduler not initialized, sync will not start");
|
||||
}
|
||||
@@ -3165,18 +3257,8 @@ pub async fn set_proxy_sync_enabled(
|
||||
}
|
||||
}
|
||||
|
||||
let mut updated_proxy = proxy.clone();
|
||||
updated_proxy.sync_enabled = enabled;
|
||||
|
||||
if !enabled {
|
||||
updated_proxy.last_sync = None;
|
||||
}
|
||||
|
||||
let proxy_file = proxy_manager.get_proxy_file_path(&proxy.id);
|
||||
let json = serde_json::to_string_pretty(&updated_proxy)
|
||||
.map_err(|e| format!("Failed to serialize proxy: {e}"))?;
|
||||
std::fs::write(&proxy_file, &json)
|
||||
.map_err(|e| format!("Failed to update proxy file {}: {e}", proxy_file.display()))?;
|
||||
let new_last_sync = if enabled { proxy.last_sync } else { None };
|
||||
proxy_manager.set_stored_proxy_sync_state(&proxy_id, enabled, new_last_sync)?;
|
||||
|
||||
let _ = events::emit("stored-proxies-changed", ());
|
||||
|
||||
@@ -3444,6 +3526,49 @@ pub fn get_unsynced_entity_counts() -> Result<UnsyncedEntityCounts, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
// Enable sync for all eligible profiles. Without this the user would see
|
||||
// groups/proxies/vpns syncing while their profiles stay local-only — the
|
||||
// long-standing source of issue #352. Encrypted mode wins when an E2E
|
||||
// password is already configured; otherwise we fall back to plain Regular.
|
||||
{
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
let desired_mode = if encryption::has_e2e_password() {
|
||||
SyncMode::Encrypted
|
||||
} else {
|
||||
SyncMode::Regular
|
||||
};
|
||||
let desired_mode_str = match desired_mode {
|
||||
SyncMode::Encrypted => "Encrypted",
|
||||
SyncMode::Regular => "Regular",
|
||||
SyncMode::Disabled => "Disabled",
|
||||
};
|
||||
for profile in &profiles {
|
||||
// Skip profiles that are already syncing (any non-Disabled mode),
|
||||
// ephemeral profiles (data wipes on quit, sync is meaningless), and
|
||||
// cross-OS profiles (the OS-specific binary isn't installed locally
|
||||
// so a sync round-trip would be one-sided).
|
||||
if profile.sync_mode != SyncMode::Disabled || profile.ephemeral || profile.is_cross_os() {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = set_profile_sync_mode(
|
||||
app_handle.clone(),
|
||||
profile.id.to_string(),
|
||||
desired_mode_str.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to enable sync for profile {} ({}): {e}",
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enable sync for all unsynced proxies
|
||||
{
|
||||
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
|
||||
@@ -3643,6 +3768,188 @@ pub async fn set_extension_group_sync_enabled(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-upload every sync-enabled entity under the current encryption state.
|
||||
/// Called after the user sets, changes, or clears their E2E password —
|
||||
/// existing remote bytes are still in the prior state, so without this they'd
|
||||
/// remain plaintext (or worse, undecryptable) until the next per-entity edit.
|
||||
///
|
||||
/// Order: profiles first (so the user can resume work as soon as profile sync
|
||||
/// completes), then proxies, groups, VPNs, extensions, extension groups.
|
||||
/// Running profiles' associated entities are deferred by 5s so the active
|
||||
/// browser session isn't disrupted mid-keystroke.
|
||||
///
|
||||
/// Progress is emitted via `e2e-rollover-progress` events with `{ stage, done, total }`.
|
||||
#[tauri::command]
|
||||
pub async fn rollover_encryption_for_all_entities(
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let _ = events::emit("e2e-rollover-started", ());
|
||||
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
|
||||
let synced_profiles: Vec<_> = profiles
|
||||
.iter()
|
||||
.filter(|p| p.sync_mode != SyncMode::Disabled)
|
||||
.collect();
|
||||
|
||||
let total_profiles = synced_profiles.len();
|
||||
let mut running_profile_ids: std::collections::HashSet<uuid::Uuid> =
|
||||
std::collections::HashSet::new();
|
||||
|
||||
for (i, profile) in synced_profiles.iter().enumerate() {
|
||||
if profile.process_id.is_some() {
|
||||
running_profile_ids.insert(profile.id);
|
||||
}
|
||||
let id_str = profile.id.to_string();
|
||||
if let Err(e) = trigger_sync_for_profile(app_handle.clone(), id_str.clone()).await {
|
||||
log::warn!("Rollover: profile {} re-sync failed: {e}", id_str);
|
||||
}
|
||||
let _ = events::emit(
|
||||
"e2e-rollover-progress",
|
||||
serde_json::json!({
|
||||
"stage": "profiles",
|
||||
"done": i + 1,
|
||||
"total": total_profiles,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Determine which entity ids are referenced by running profiles, so we can
|
||||
// defer their re-upload (changing their files mid-session would cause the
|
||||
// running browser to see a different proxy/extension config than what it
|
||||
// launched with).
|
||||
let mut deferred_proxy_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut deferred_vpn_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut deferred_group_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for p in &profiles {
|
||||
if running_profile_ids.contains(&p.id) {
|
||||
if let Some(id) = &p.proxy_id {
|
||||
deferred_proxy_ids.insert(id.clone());
|
||||
}
|
||||
if let Some(id) = &p.vpn_id {
|
||||
deferred_vpn_ids.insert(id.clone());
|
||||
}
|
||||
if let Some(id) = &p.group_id {
|
||||
deferred_group_ids.insert(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
|
||||
let synced_proxies: Vec<_> = proxies.iter().filter(|p| p.sync_enabled).collect();
|
||||
let total_proxies = synced_proxies.len();
|
||||
let mut deferred = Vec::new();
|
||||
for (i, proxy) in synced_proxies.iter().enumerate() {
|
||||
if deferred_proxy_ids.contains(&proxy.id) {
|
||||
deferred.push(proxy.id.clone());
|
||||
} else if let Some(scheduler) = super::get_global_scheduler() {
|
||||
scheduler.queue_proxy_sync(proxy.id.clone()).await;
|
||||
}
|
||||
let _ = events::emit(
|
||||
"e2e-rollover-progress",
|
||||
serde_json::json!({"stage": "proxies", "done": i + 1, "total": total_proxies}),
|
||||
);
|
||||
}
|
||||
|
||||
let groups = {
|
||||
let gm = crate::group_manager::GROUP_MANAGER.lock().unwrap();
|
||||
gm.get_all_groups()
|
||||
.map_err(|e| format!("Failed to get groups: {e}"))?
|
||||
};
|
||||
let synced_groups: Vec<_> = groups.iter().filter(|g| g.sync_enabled).collect();
|
||||
let total_groups = synced_groups.len();
|
||||
let mut deferred_groups = Vec::new();
|
||||
for (i, group) in synced_groups.iter().enumerate() {
|
||||
if deferred_group_ids.contains(&group.id) {
|
||||
deferred_groups.push(group.id.clone());
|
||||
} else if let Some(scheduler) = super::get_global_scheduler() {
|
||||
scheduler.queue_group_sync(group.id.clone()).await;
|
||||
}
|
||||
let _ = events::emit(
|
||||
"e2e-rollover-progress",
|
||||
serde_json::json!({"stage": "groups", "done": i + 1, "total": total_groups}),
|
||||
);
|
||||
}
|
||||
|
||||
let vpns = {
|
||||
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
|
||||
storage
|
||||
.list_configs()
|
||||
.map_err(|e| format!("Failed to list VPN configs: {e}"))?
|
||||
};
|
||||
let synced_vpns: Vec<_> = vpns.iter().filter(|v| v.sync_enabled).collect();
|
||||
let total_vpns = synced_vpns.len();
|
||||
let mut deferred_vpns = Vec::new();
|
||||
for (i, config) in synced_vpns.iter().enumerate() {
|
||||
if deferred_vpn_ids.contains(&config.id) {
|
||||
deferred_vpns.push(config.id.clone());
|
||||
} else if let Some(scheduler) = super::get_global_scheduler() {
|
||||
scheduler.queue_vpn_sync(config.id.clone()).await;
|
||||
}
|
||||
let _ = events::emit(
|
||||
"e2e-rollover-progress",
|
||||
serde_json::json!({"stage": "vpns", "done": i + 1, "total": total_vpns}),
|
||||
);
|
||||
}
|
||||
|
||||
let extensions = {
|
||||
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
em.list_extensions()
|
||||
.map_err(|e| format!("Failed to list extensions: {e}"))?
|
||||
};
|
||||
let synced_exts: Vec<_> = extensions.iter().filter(|e| e.sync_enabled).collect();
|
||||
let total_exts = synced_exts.len();
|
||||
for (i, ext) in synced_exts.iter().enumerate() {
|
||||
if let Some(scheduler) = super::get_global_scheduler() {
|
||||
scheduler.queue_extension_sync(ext.id.clone()).await;
|
||||
}
|
||||
let _ = events::emit(
|
||||
"e2e-rollover-progress",
|
||||
serde_json::json!({"stage": "extensions", "done": i + 1, "total": total_exts}),
|
||||
);
|
||||
}
|
||||
|
||||
let ext_groups = {
|
||||
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
em.list_groups()
|
||||
.map_err(|e| format!("Failed to list extension groups: {e}"))?
|
||||
};
|
||||
let synced_ext_groups: Vec<_> = ext_groups.iter().filter(|g| g.sync_enabled).collect();
|
||||
let total_eg = synced_ext_groups.len();
|
||||
for (i, group) in synced_ext_groups.iter().enumerate() {
|
||||
if let Some(scheduler) = super::get_global_scheduler() {
|
||||
scheduler.queue_extension_group_sync(group.id.clone()).await;
|
||||
}
|
||||
let _ = events::emit(
|
||||
"e2e-rollover-progress",
|
||||
serde_json::json!({"stage": "extension_groups", "done": i + 1, "total": total_eg}),
|
||||
);
|
||||
}
|
||||
|
||||
if !deferred.is_empty() || !deferred_groups.is_empty() || !deferred_vpns.is_empty() {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
if let Some(scheduler) = super::get_global_scheduler() {
|
||||
for id in deferred {
|
||||
scheduler.queue_proxy_sync(id).await;
|
||||
}
|
||||
for id in deferred_groups {
|
||||
scheduler.queue_group_sync(id).await;
|
||||
}
|
||||
for id in deferred_vpns {
|
||||
scheduler.queue_vpn_sync(id).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let _ = events::emit("e2e-rollover-completed", ());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -52,6 +52,10 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
|
||||
"**/BrowserMetrics*",
|
||||
"**/.DS_Store",
|
||||
".donut-sync/**",
|
||||
// Local-only marker recording when Wayfern last refreshed this profile's
|
||||
// fingerprint. Each device decides its own refresh cadence, so syncing
|
||||
// this would cause one device's refresh to silence others.
|
||||
".last-fp-refresh",
|
||||
];
|
||||
|
||||
/// A single file entry in the manifest
|
||||
|
||||
@@ -9,11 +9,12 @@ pub mod types;
|
||||
pub use client::SyncClient;
|
||||
pub use encryption::{check_has_e2e_password, delete_e2e_password, set_e2e_password};
|
||||
pub use engine::{
|
||||
enable_group_sync_if_needed, enable_proxy_sync_if_needed, enable_sync_for_all_entities,
|
||||
enable_vpn_sync_if_needed, get_unsynced_entity_counts, is_group_in_use_by_synced_profile,
|
||||
is_group_used_by_synced_profile, is_proxy_in_use_by_synced_profile,
|
||||
is_proxy_used_by_synced_profile, is_sync_configured, is_vpn_in_use_by_synced_profile,
|
||||
is_vpn_used_by_synced_profile, request_profile_sync, set_extension_group_sync_enabled,
|
||||
enable_extension_group_sync_if_needed, enable_group_sync_if_needed, enable_proxy_sync_if_needed,
|
||||
enable_sync_for_all_entities, enable_vpn_sync_if_needed, get_unsynced_entity_counts,
|
||||
is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
|
||||
is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured,
|
||||
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, request_profile_sync,
|
||||
rollover_encryption_for_all_entities, set_extension_group_sync_enabled,
|
||||
set_extension_sync_enabled, set_group_sync_enabled, set_profile_sync_mode,
|
||||
set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile, SyncEngine,
|
||||
};
|
||||
|
||||
@@ -639,14 +639,25 @@ impl WayfernManager {
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
log::info!("Wayfern token not ready for paid user, waiting...");
|
||||
for _ in 0..15 {
|
||||
// Brief wait for the background token fetch — when the API is healthy
|
||||
// the token usually lands in well under a second. If api.donutbrowser.com
|
||||
// is unreachable we don't want to gate the whole launch on it; the
|
||||
// browser still works without the token (cross-OS fingerprinting just
|
||||
// won't be enabled for this session, and the next launch will pick it
|
||||
// up once the token arrives).
|
||||
log::info!("Wayfern token not ready for paid user, waiting briefly...");
|
||||
for _ in 0..3 {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
||||
if wayfern_token.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if wayfern_token.is_none() {
|
||||
log::warn!(
|
||||
"Wayfern token still unavailable after wait; launching without it (api.donutbrowser.com may be unreachable)"
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(ref token) = wayfern_token {
|
||||
args.push(format!("--wayfern-token={token}"));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.22.4",
|
||||
"version": "0.24.1",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
+442
-124
@@ -5,6 +5,7 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AccountPage } from "@/components/account-page";
|
||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
||||
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
||||
@@ -12,10 +13,10 @@ import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
||||
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
|
||||
import { CreateProfileDialog } from "@/components/create-profile-dialog";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import { DeviceCodeVerifyDialog } from "@/components/device-code-verify-dialog";
|
||||
import { ExtensionGroupAssignmentDialog } from "@/components/extension-group-assignment-dialog";
|
||||
import { ExtensionManagementDialog } from "@/components/extension-management-dialog";
|
||||
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
|
||||
import { GroupBadges } from "@/components/group-badges";
|
||||
import { GroupManagementDialog } from "@/components/group-management-dialog";
|
||||
import HomeHeader from "@/components/home-header";
|
||||
import { ImportProfileDialog } from "@/components/import-profile-dialog";
|
||||
@@ -23,10 +24,15 @@ import { IntegrationsDialog } from "@/components/integrations-dialog";
|
||||
import { LaunchOnLoginDialog } from "@/components/launch-on-login-dialog";
|
||||
import { PermissionDialog } from "@/components/permission-dialog";
|
||||
import { ProfilesDataTable } from "@/components/profile-data-table";
|
||||
import {
|
||||
type PasswordDialogMode,
|
||||
ProfilePasswordDialog,
|
||||
} from "@/components/profile-password-dialog";
|
||||
import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
|
||||
import { ProfileSyncDialog } from "@/components/profile-sync-dialog";
|
||||
import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
|
||||
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
|
||||
import { type AppPage, RailNav } from "@/components/rail-nav";
|
||||
import { SettingsDialog } from "@/components/settings-dialog";
|
||||
import { SyncAllDialog } from "@/components/sync-all-dialog";
|
||||
import { SyncConfigDialog } from "@/components/sync-config-dialog";
|
||||
@@ -46,6 +52,7 @@ import { useUpdateNotifications } from "@/hooks/use-update-notifications";
|
||||
import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import {
|
||||
dismissToast,
|
||||
showErrorToast,
|
||||
@@ -135,6 +142,13 @@ export default function Home() {
|
||||
|
||||
const syncUnlocked = crossOsUnlocked || selfHostedSyncConfigured;
|
||||
|
||||
const [currentPage, setCurrentPage] = useState<AppPage>("profiles");
|
||||
const [accountDialogOpen, setAccountDialogOpen] = useState(false);
|
||||
// Tracks which tab inside the shared proxy-management page should be active.
|
||||
// The VPN rail item routes to the same page but pre-selects the VPN tab.
|
||||
const [proxyManagementInitialTab, setProxyManagementInitialTab] = useState<
|
||||
"proxies" | "vpns"
|
||||
>("proxies");
|
||||
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
|
||||
@@ -169,7 +183,7 @@ export default function Home() {
|
||||
const [selectedProfilesForCookies, setSelectedProfilesForCookies] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string>("default");
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string>("__all__");
|
||||
const [selectedProfilesForGroup, setSelectedProfilesForGroup] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
@@ -182,6 +196,11 @@ export default function Home() {
|
||||
const [currentProfileForCamoufoxConfig, setCurrentProfileForCamoufoxConfig] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [cloneProfile, setCloneProfile] = useState<BrowserProfile | null>(null);
|
||||
const [passwordDialogProfile, setPasswordDialogProfile] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [passwordDialogMode, setPasswordDialogMode] =
|
||||
useState<PasswordDialogMode>("set");
|
||||
const pendingLaunchAfterUnlockRef = useRef<BrowserProfile | null>(null);
|
||||
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
|
||||
const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false);
|
||||
const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false);
|
||||
@@ -197,6 +216,7 @@ export default function Home() {
|
||||
useState(false);
|
||||
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
||||
const [syncConfigDialogOpen, setSyncConfigDialogOpen] = useState(false);
|
||||
const [deviceCodeDialogOpen, setDeviceCodeDialogOpen] = useState(false);
|
||||
const [syncAllDialogOpen, setSyncAllDialogOpen] = useState(false);
|
||||
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
|
||||
const [currentProfileForSync, setCurrentProfileForSync] =
|
||||
@@ -209,6 +229,53 @@ export default function Home() {
|
||||
setSelectedProfiles([]);
|
||||
}, []);
|
||||
|
||||
const handleRailNavigate = useCallback((page: AppPage) => {
|
||||
// Always reset every sub-page-able dialog before opening the next one,
|
||||
// so navigating from one rail item to another doesn't stack two
|
||||
// sub-pages on top of each other.
|
||||
setSettingsDialogOpen(false);
|
||||
setProxyManagementDialogOpen(false);
|
||||
setExtensionManagementDialogOpen(false);
|
||||
setGroupManagementDialogOpen(false);
|
||||
setIntegrationsDialogOpen(false);
|
||||
setImportProfileDialogOpen(false);
|
||||
setAccountDialogOpen(false);
|
||||
|
||||
setCurrentPage(page);
|
||||
switch (page) {
|
||||
case "profiles":
|
||||
break;
|
||||
case "settings":
|
||||
setSettingsDialogOpen(true);
|
||||
break;
|
||||
case "proxies":
|
||||
setProxyManagementInitialTab("proxies");
|
||||
setProxyManagementDialogOpen(true);
|
||||
break;
|
||||
case "extensions":
|
||||
setExtensionManagementDialogOpen(true);
|
||||
break;
|
||||
case "groups":
|
||||
setGroupManagementDialogOpen(true);
|
||||
break;
|
||||
case "integrations":
|
||||
setIntegrationsDialogOpen(true);
|
||||
break;
|
||||
case "import":
|
||||
setImportProfileDialogOpen(true);
|
||||
break;
|
||||
case "vpns":
|
||||
// VPNs share the proxy management page; pre-select the VPN tab so
|
||||
// the user lands directly on the right list.
|
||||
setProxyManagementInitialTab("vpns");
|
||||
setProxyManagementDialogOpen(true);
|
||||
break;
|
||||
case "account":
|
||||
setAccountDialogOpen(true);
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Check for missing binaries and offer to download them
|
||||
const checkMissingBinaries = useCallback(async () => {
|
||||
try {
|
||||
@@ -394,21 +461,32 @@ export default function Home() {
|
||||
}
|
||||
}, [isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized]);
|
||||
|
||||
const checkNextPermission = useCallback(() => {
|
||||
try {
|
||||
if (!isMicrophoneAccessGranted) {
|
||||
setCurrentPermissionType("microphone");
|
||||
setPermissionDialogOpen(true);
|
||||
} else if (!isCameraAccessGranted) {
|
||||
setCurrentPermissionType("camera");
|
||||
setPermissionDialogOpen(true);
|
||||
} else {
|
||||
setPermissionDialogOpen(false);
|
||||
const checkNextPermission = useCallback(
|
||||
(justGranted?: PermissionType) => {
|
||||
try {
|
||||
// Treat the just-granted permission as already granted even if our
|
||||
// own usePermissions instance hasn't observed it yet — it polls on a
|
||||
// 5 s cadence and would otherwise leave the dialog stuck on the
|
||||
// permission the user just successfully granted.
|
||||
const micGranted =
|
||||
isMicrophoneAccessGranted || justGranted === "microphone";
|
||||
const camGranted = isCameraAccessGranted || justGranted === "camera";
|
||||
|
||||
if (!micGranted) {
|
||||
setCurrentPermissionType("microphone");
|
||||
setPermissionDialogOpen(true);
|
||||
} else if (!camGranted) {
|
||||
setCurrentPermissionType("camera");
|
||||
setPermissionDialogOpen(true);
|
||||
} else {
|
||||
setPermissionDialogOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check next permission:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check next permission:", error);
|
||||
}
|
||||
}, [isMicrophoneAccessGranted, isCameraAccessGranted]);
|
||||
},
|
||||
[isMicrophoneAccessGranted, isCameraAccessGranted],
|
||||
);
|
||||
|
||||
const listenForUrlEvents = useCallback(async () => {
|
||||
try {
|
||||
@@ -519,6 +597,7 @@ export default function Home() {
|
||||
ephemeral?: boolean;
|
||||
dnsBlocklist?: string;
|
||||
launchHook?: string;
|
||||
password?: string;
|
||||
}) => {
|
||||
try {
|
||||
const profile = await invoke<BrowserProfile>(
|
||||
@@ -552,6 +631,21 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
if (profileData.password && !profileData.ephemeral) {
|
||||
try {
|
||||
await invoke("set_profile_password", {
|
||||
profileId: profile.id,
|
||||
password: profileData.password,
|
||||
});
|
||||
} catch (err) {
|
||||
showErrorToast(
|
||||
t("errors.setProfilePasswordFailed", {
|
||||
error: translateBackendError(t, err),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
} catch (error) {
|
||||
showErrorToast(
|
||||
@@ -568,6 +662,23 @@ export default function Home() {
|
||||
async (profile: BrowserProfile) => {
|
||||
console.log("Starting launch for profile:", profile.name);
|
||||
|
||||
// Password-protected: must be unlocked before launch
|
||||
if (profile.password_protected) {
|
||||
try {
|
||||
const isLocked = await invoke<boolean>("is_profile_locked", {
|
||||
profileId: profile.id,
|
||||
});
|
||||
if (isLocked) {
|
||||
pendingLaunchAfterUnlockRef.current = profile;
|
||||
setPasswordDialogMode("unlock");
|
||||
setPasswordDialogProfile(profile);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check profile lock state:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Show one-time warning about window resizing for fingerprinted browsers
|
||||
if (profile.browser === "camoufox" || profile.browser === "wayfern") {
|
||||
try {
|
||||
@@ -610,6 +721,24 @@ export default function Home() {
|
||||
setCloneProfile(profile);
|
||||
}, []);
|
||||
|
||||
const handleSetPassword = useCallback((profile: BrowserProfile) => {
|
||||
pendingLaunchAfterUnlockRef.current = null;
|
||||
setPasswordDialogMode("set");
|
||||
setPasswordDialogProfile(profile);
|
||||
}, []);
|
||||
|
||||
const handleChangePassword = useCallback((profile: BrowserProfile) => {
|
||||
pendingLaunchAfterUnlockRef.current = null;
|
||||
setPasswordDialogMode("change");
|
||||
setPasswordDialogProfile(profile);
|
||||
}, []);
|
||||
|
||||
const handleRemovePassword = useCallback((profile: BrowserProfile) => {
|
||||
pendingLaunchAfterUnlockRef.current = null;
|
||||
setPasswordDialogMode("remove");
|
||||
setPasswordDialogProfile(profile);
|
||||
}, []);
|
||||
|
||||
const handleDeleteProfile = useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
console.log("Attempting to delete profile:", profile.name);
|
||||
@@ -968,6 +1097,84 @@ export default function Home() {
|
||||
profiles.length,
|
||||
]);
|
||||
|
||||
// E2E encryption listeners — surface password-required prompts and rollover
|
||||
// progress so the user isn't left guessing whether sealing finished.
|
||||
useEffect(() => {
|
||||
let unlistenRequired: (() => void) | undefined;
|
||||
let unlistenStarted: (() => void) | undefined;
|
||||
let unlistenProgress: (() => void) | undefined;
|
||||
let unlistenCompleted: (() => void) | undefined;
|
||||
|
||||
void (async () => {
|
||||
unlistenRequired = await listen(
|
||||
"profile-sync-e2e-password-required",
|
||||
() => {
|
||||
showToast({
|
||||
id: "e2e-password-required",
|
||||
type: "error",
|
||||
title: t("encryption.required.title"),
|
||||
description: t("encryption.required.description"),
|
||||
duration: 12000,
|
||||
action: {
|
||||
label: t("encryption.required.openSettings"),
|
||||
onClick: () => {
|
||||
setSettingsDialogOpen(true);
|
||||
setCurrentPage("settings");
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
unlistenStarted = await listen("e2e-rollover-started", () => {
|
||||
showToast({
|
||||
id: "e2e-rollover",
|
||||
type: "loading",
|
||||
title: t("encryption.rollover.startedTitle"),
|
||||
description: t("encryption.rollover.startedDescription"),
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
});
|
||||
|
||||
unlistenProgress = await listen<{
|
||||
stage: string;
|
||||
done: number;
|
||||
total: number;
|
||||
}>("e2e-rollover-progress", (event) => {
|
||||
const { stage, done, total } = event.payload;
|
||||
showToast({
|
||||
id: "e2e-rollover",
|
||||
type: "loading",
|
||||
title: t("encryption.rollover.progressTitle", {
|
||||
stage: t(`encryption.rollover.stage.${stage}`),
|
||||
}),
|
||||
description: t("encryption.rollover.progressDescription", {
|
||||
done,
|
||||
total,
|
||||
}),
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
});
|
||||
|
||||
unlistenCompleted = await listen("e2e-rollover-completed", () => {
|
||||
showToast({
|
||||
id: "e2e-rollover",
|
||||
type: "success",
|
||||
title: t("encryption.rollover.completedTitle"),
|
||||
description: t("encryption.rollover.completedDescription"),
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
unlistenRequired?.();
|
||||
unlistenStarted?.();
|
||||
unlistenProgress?.();
|
||||
unlistenCompleted?.();
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
// Show warning for non-wayfern/camoufox profiles (support ending March 15, 2026)
|
||||
useEffect(() => {
|
||||
if (profiles.length === 0) return;
|
||||
@@ -1035,8 +1242,11 @@ export default function Home() {
|
||||
const filteredProfiles = useMemo(() => {
|
||||
let filtered = profiles;
|
||||
|
||||
// Filter by group
|
||||
if (!selectedGroupId || selectedGroupId === "default") {
|
||||
// Filter by group. "__all__" is a virtual filter that shows every
|
||||
// profile regardless of group; "default" shows ungrouped profiles.
|
||||
if (selectedGroupId === "__all__") {
|
||||
filtered = profiles;
|
||||
} else if (!selectedGroupId || selectedGroupId === "default") {
|
||||
filtered = profiles.filter((profile) => !profile.group_id);
|
||||
} else {
|
||||
filtered = profiles.filter(
|
||||
@@ -1068,64 +1278,162 @@ export default function Home() {
|
||||
// Update loading states
|
||||
const isLoading = profilesLoading || groupsLoading || proxiesLoading;
|
||||
|
||||
const subPageTitle =
|
||||
currentPage === "profiles"
|
||||
? undefined
|
||||
: currentPage === "import"
|
||||
? t("pageTitle.import")
|
||||
: t(`pageTitle.${currentPage}`);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="w-full">
|
||||
<HomeHeader
|
||||
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
||||
onGroupManagementDialogOpen={setGroupManagementDialogOpen}
|
||||
onImportProfileDialogOpen={setImportProfileDialogOpen}
|
||||
onProxyManagementDialogOpen={setProxyManagementDialogOpen}
|
||||
onSettingsDialogOpen={setSettingsDialogOpen}
|
||||
onSyncConfigDialogOpen={setSyncConfigDialogOpen}
|
||||
onIntegrationsDialogOpen={setIntegrationsDialogOpen}
|
||||
onExtensionManagementDialogOpen={setExtensionManagementDialogOpen}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full mt-2.5">
|
||||
<GroupBadges
|
||||
selectedGroupId={selectedGroupId}
|
||||
onGroupSelect={handleSelectGroup}
|
||||
groups={groupsData}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<ProfilesDataTable
|
||||
profiles={filteredProfiles}
|
||||
onLaunchProfile={launchProfile}
|
||||
onKillProfile={handleKillProfile}
|
||||
onCloneProfile={handleCloneProfile}
|
||||
onDeleteProfile={handleDeleteProfile}
|
||||
onRenameProfile={handleRenameProfile}
|
||||
onConfigureCamoufox={handleConfigureCamoufox}
|
||||
onCopyCookiesToProfile={handleCopyCookiesToProfile}
|
||||
onOpenCookieManagement={handleOpenCookieManagement}
|
||||
runningProfiles={runningProfiles}
|
||||
isUpdating={isUpdating}
|
||||
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
|
||||
onAssignProfilesToGroup={handleAssignProfilesToGroup}
|
||||
selectedGroupId={selectedGroupId}
|
||||
selectedProfiles={selectedProfiles}
|
||||
onSelectedProfilesChange={setSelectedProfiles}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onBulkGroupAssignment={handleBulkGroupAssignment}
|
||||
onBulkProxyAssignment={handleBulkProxyAssignment}
|
||||
onBulkCopyCookies={handleBulkCopyCookies}
|
||||
onBulkExtensionGroupAssignment={handleBulkExtensionGroupAssignment}
|
||||
onAssignExtensionGroup={handleAssignExtensionGroup}
|
||||
onOpenProfileSyncDialog={handleOpenProfileSyncDialog}
|
||||
onToggleProfileSync={handleToggleProfileSync}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
syncUnlocked={syncUnlocked}
|
||||
getProfileSyncInfo={getProfileSyncInfo}
|
||||
onLaunchWithSync={(profile) => {
|
||||
setSyncLeaderProfile(profile);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<div className="flex flex-col h-screen bg-background font-(family-name:--font-geist-sans)">
|
||||
<HomeHeader
|
||||
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
groups={groupsData}
|
||||
selectedGroupId={selectedGroupId}
|
||||
onGroupSelect={handleSelectGroup}
|
||||
pageTitle={subPageTitle}
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<RailNav currentPage={currentPage} onNavigate={handleRailNavigate} />
|
||||
<main className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||
{currentPage === "profiles" && (
|
||||
<div className="px-3 pt-2.5 flex flex-col flex-1 min-h-0">
|
||||
{isLoading && groupsData.length === 0 ? null : null}
|
||||
<ProfilesDataTable
|
||||
profiles={filteredProfiles}
|
||||
onLaunchProfile={launchProfile}
|
||||
onKillProfile={handleKillProfile}
|
||||
onCloneProfile={handleCloneProfile}
|
||||
onSetPassword={handleSetPassword}
|
||||
onChangePassword={handleChangePassword}
|
||||
onRemovePassword={handleRemovePassword}
|
||||
onDeleteProfile={handleDeleteProfile}
|
||||
onRenameProfile={handleRenameProfile}
|
||||
onConfigureCamoufox={handleConfigureCamoufox}
|
||||
onCopyCookiesToProfile={handleCopyCookiesToProfile}
|
||||
onOpenCookieManagement={handleOpenCookieManagement}
|
||||
runningProfiles={runningProfiles}
|
||||
isUpdating={isUpdating}
|
||||
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
|
||||
onAssignProfilesToGroup={handleAssignProfilesToGroup}
|
||||
selectedGroupId={selectedGroupId}
|
||||
selectedProfiles={selectedProfiles}
|
||||
onSelectedProfilesChange={setSelectedProfiles}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onBulkGroupAssignment={handleBulkGroupAssignment}
|
||||
onBulkProxyAssignment={handleBulkProxyAssignment}
|
||||
onBulkCopyCookies={handleBulkCopyCookies}
|
||||
onBulkExtensionGroupAssignment={
|
||||
handleBulkExtensionGroupAssignment
|
||||
}
|
||||
onAssignExtensionGroup={handleAssignExtensionGroup}
|
||||
onOpenProfileSyncDialog={handleOpenProfileSyncDialog}
|
||||
onToggleProfileSync={handleToggleProfileSync}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
syncUnlocked={syncUnlocked}
|
||||
getProfileSyncInfo={getProfileSyncInfo}
|
||||
onLaunchWithSync={(profile) => {
|
||||
setSyncLeaderProfile(profile);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settingsDialogOpen && (
|
||||
<SettingsDialog
|
||||
isOpen={settingsDialogOpen}
|
||||
onClose={() => {
|
||||
setSettingsDialogOpen(false);
|
||||
setCurrentPage("profiles");
|
||||
}}
|
||||
onIntegrationsOpen={() => {
|
||||
setSettingsDialogOpen(false);
|
||||
setIntegrationsDialogOpen(true);
|
||||
setCurrentPage("integrations");
|
||||
}}
|
||||
subPage={currentPage === "settings"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{integrationsDialogOpen && (
|
||||
<IntegrationsDialog
|
||||
isOpen={integrationsDialogOpen}
|
||||
onClose={() => {
|
||||
setIntegrationsDialogOpen(false);
|
||||
setCurrentPage("profiles");
|
||||
}}
|
||||
subPage={currentPage === "integrations"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{proxyManagementDialogOpen && (
|
||||
<ProxyManagementDialog
|
||||
isOpen={proxyManagementDialogOpen}
|
||||
onClose={() => {
|
||||
setProxyManagementDialogOpen(false);
|
||||
setCurrentPage("profiles");
|
||||
}}
|
||||
subPage={currentPage === "proxies" || currentPage === "vpns"}
|
||||
initialTab={proxyManagementInitialTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
{groupManagementDialogOpen && (
|
||||
<GroupManagementDialog
|
||||
isOpen={groupManagementDialogOpen}
|
||||
onClose={() => {
|
||||
setGroupManagementDialogOpen(false);
|
||||
setCurrentPage("profiles");
|
||||
}}
|
||||
onGroupManagementComplete={handleGroupManagementComplete}
|
||||
subPage={currentPage === "groups"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{extensionManagementDialogOpen && (
|
||||
<ExtensionManagementDialog
|
||||
isOpen={extensionManagementDialogOpen}
|
||||
onClose={() => {
|
||||
setExtensionManagementDialogOpen(false);
|
||||
setCurrentPage("profiles");
|
||||
}}
|
||||
limitedMode={false}
|
||||
subPage={currentPage === "extensions"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{importProfileDialogOpen && (
|
||||
<ImportProfileDialog
|
||||
isOpen={importProfileDialogOpen}
|
||||
onClose={() => {
|
||||
setImportProfileDialogOpen(false);
|
||||
setCurrentPage("profiles");
|
||||
}}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
subPage={currentPage === "import"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{accountDialogOpen && (
|
||||
<AccountPage
|
||||
isOpen={accountDialogOpen}
|
||||
onClose={() => {
|
||||
setAccountDialogOpen(false);
|
||||
setCurrentPage("profiles");
|
||||
}}
|
||||
subPage={currentPage === "account"}
|
||||
onOpenSignIn={() => {
|
||||
setAccountDialogOpen(false);
|
||||
setCurrentPage("profiles");
|
||||
setDeviceCodeDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<CreateProfileDialog
|
||||
isOpen={createProfileDialogOpen}
|
||||
@@ -1137,39 +1445,6 @@ export default function Home() {
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
|
||||
<SettingsDialog
|
||||
isOpen={settingsDialogOpen}
|
||||
onClose={() => {
|
||||
setSettingsDialogOpen(false);
|
||||
}}
|
||||
onIntegrationsOpen={() => {
|
||||
setSettingsDialogOpen(false);
|
||||
setIntegrationsDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<IntegrationsDialog
|
||||
isOpen={integrationsDialogOpen}
|
||||
onClose={() => {
|
||||
setIntegrationsDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ImportProfileDialog
|
||||
isOpen={importProfileDialogOpen}
|
||||
onClose={() => {
|
||||
setImportProfileDialogOpen(false);
|
||||
}}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
|
||||
<ProxyManagementDialog
|
||||
isOpen={proxyManagementDialogOpen}
|
||||
onClose={() => {
|
||||
setProxyManagementDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{pendingUrls.map((pendingUrl) => (
|
||||
<ProfileSelectorDialog
|
||||
key={pendingUrl.id}
|
||||
@@ -1202,6 +1477,44 @@ export default function Home() {
|
||||
profile={cloneProfile}
|
||||
/>
|
||||
|
||||
<ProfilePasswordDialog
|
||||
isOpen={!!passwordDialogProfile}
|
||||
onClose={() => {
|
||||
pendingLaunchAfterUnlockRef.current = null;
|
||||
setPasswordDialogProfile(null);
|
||||
}}
|
||||
profile={passwordDialogProfile}
|
||||
mode={passwordDialogMode}
|
||||
onSuccess={(p) => {
|
||||
// Resume pending launch after unlock.
|
||||
if (
|
||||
passwordDialogMode === "unlock" &&
|
||||
pendingLaunchAfterUnlockRef.current?.id === p.id
|
||||
) {
|
||||
const target = pendingLaunchAfterUnlockRef.current;
|
||||
pendingLaunchAfterUnlockRef.current = null;
|
||||
void launchProfile(target);
|
||||
}
|
||||
// On set/change/remove, the profile's encryption state changed.
|
||||
// Push that state to the sync server immediately so other devices
|
||||
// see the new envelope before they next pull. Skip if the profile
|
||||
// is currently running — its files would be in flux.
|
||||
if (
|
||||
(passwordDialogMode === "set" ||
|
||||
passwordDialogMode === "change" ||
|
||||
passwordDialogMode === "remove") &&
|
||||
!runningProfiles.has(p.id) &&
|
||||
p.sync_mode !== "Disabled"
|
||||
) {
|
||||
void invoke("request_profile_sync", { profileId: p.id }).catch(
|
||||
(err: unknown) => {
|
||||
console.error("post-password sync failed", err);
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<CamoufoxConfigDialog
|
||||
isOpen={camoufoxConfigDialogOpen}
|
||||
onClose={() => {
|
||||
@@ -1218,22 +1531,6 @@ export default function Home() {
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
|
||||
<GroupManagementDialog
|
||||
isOpen={groupManagementDialogOpen}
|
||||
onClose={() => {
|
||||
setGroupManagementDialogOpen(false);
|
||||
}}
|
||||
onGroupManagementComplete={handleGroupManagementComplete}
|
||||
/>
|
||||
|
||||
<ExtensionManagementDialog
|
||||
isOpen={extensionManagementDialogOpen}
|
||||
onClose={() => {
|
||||
setExtensionManagementDialogOpen(false);
|
||||
}}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
|
||||
<GroupAssignmentDialog
|
||||
isOpen={groupAssignmentDialogOpen}
|
||||
onClose={() => {
|
||||
@@ -1316,8 +1613,29 @@ export default function Home() {
|
||||
setSyncAllDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
onLoginStarted={() => {
|
||||
// Hand the verify step off to its own dialog. We close this one
|
||||
// first so the verify dialog isn't stacked on top of it (and
|
||||
// can't end up stacked on top of the profile selector either).
|
||||
setSyncConfigDialogOpen(false);
|
||||
setDeviceCodeDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Only render while no profile-selector flow is in progress, so the
|
||||
verify dialog never lands on top of a deep-link-triggered selector. */}
|
||||
{pendingUrls.length === 0 && (
|
||||
<DeviceCodeVerifyDialog
|
||||
isOpen={deviceCodeDialogOpen}
|
||||
onClose={(loginOccurred) => {
|
||||
setDeviceCodeDialogOpen(false);
|
||||
if (loginOccurred) {
|
||||
setSyncAllDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SyncAllDialog
|
||||
isOpen={syncAllDialogOpen}
|
||||
onClose={() => {
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LuCloud,
|
||||
LuEye,
|
||||
LuEyeOff,
|
||||
LuLogOut,
|
||||
LuRefreshCw,
|
||||
LuUser,
|
||||
} from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SyncSettings } from "@/types";
|
||||
|
||||
interface AccountPageProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
subPage?: boolean;
|
||||
onOpenSignIn: () => void;
|
||||
}
|
||||
|
||||
type ConnectionStatus = "unknown" | "testing" | "connected" | "error";
|
||||
|
||||
export function AccountPage({
|
||||
isOpen,
|
||||
onClose,
|
||||
subPage,
|
||||
onOpenSignIn,
|
||||
}: AccountPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
user,
|
||||
isLoggedIn,
|
||||
isLoading: isCloudLoading,
|
||||
logout,
|
||||
refreshProfile,
|
||||
} = useCloudAuth();
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
|
||||
// Self-hosted server state. Loaded once when the dialog opens and persisted
|
||||
// via `save_sync_settings` so the rest of the app picks up the new URL/token
|
||||
// from `SettingsManager`.
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [isSavingSelfHosted, setIsSavingSelfHosted] = useState(false);
|
||||
const [isTestingConnection, setIsTestingConnection] = useState(false);
|
||||
const [connectionStatus, setConnectionStatus] =
|
||||
useState<ConnectionStatus>("unknown");
|
||||
|
||||
const hasConfig = Boolean(serverUrl && token);
|
||||
// Self-hosted and cloud are mutually exclusive — both share the same sync
|
||||
// engine and a profile can't be sync'd to two backends. The tab trigger is
|
||||
// disabled here AND the backend rejects mixed state (see `save_sync_settings`
|
||||
// / `cloud_logout`), so even if someone bypasses the UI we don't end up
|
||||
// with split-brain.
|
||||
const selfHostedDisabled = isLoggedIn || isCloudLoading;
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await refreshProfile();
|
||||
showSuccessToast(t("account.refreshed"));
|
||||
} catch (e) {
|
||||
showErrorToast(String(e));
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoggingOut(true);
|
||||
try {
|
||||
await logout();
|
||||
// The backend wipes sync URL + token as part of cloud_logout (see
|
||||
// `cloud_auth::cloud_logout`); pull the now-empty settings back into
|
||||
// the form so a user who flips to the Self-hosted tab doesn't see the
|
||||
// pre-logout production URL still sitting there.
|
||||
await loadSelfHostedSettings();
|
||||
showSuccessToast(t("account.loggedOut"));
|
||||
} catch (e) {
|
||||
showErrorToast(String(e));
|
||||
} finally {
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSelfHostedSettings = useCallback(async () => {
|
||||
try {
|
||||
const settings = await invoke<SyncSettings>("get_sync_settings");
|
||||
setServerUrl(settings.sync_server_url ?? "");
|
||||
setToken(settings.sync_token ?? "");
|
||||
setConnectionStatus(
|
||||
settings.sync_server_url && settings.sync_token ? "unknown" : "unknown",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to load sync settings:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadSelfHostedSettings();
|
||||
}
|
||||
}, [isOpen, loadSelfHostedSettings]);
|
||||
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
if (!serverUrl) {
|
||||
showErrorToast(t("sync.config.serverUrlRequired"));
|
||||
return;
|
||||
}
|
||||
setIsTestingConnection(true);
|
||||
setConnectionStatus("testing");
|
||||
try {
|
||||
const healthUrl = `${serverUrl.replace(/\/$/, "")}/health`;
|
||||
const response = await fetch(healthUrl);
|
||||
if (response.ok) {
|
||||
setConnectionStatus("connected");
|
||||
showSuccessToast(t("sync.config.connectionSuccess"));
|
||||
} else {
|
||||
setConnectionStatus("error");
|
||||
showErrorToast(t("sync.config.serverError"));
|
||||
}
|
||||
} catch {
|
||||
setConnectionStatus("error");
|
||||
showErrorToast(t("sync.config.connectFailed"));
|
||||
} finally {
|
||||
setIsTestingConnection(false);
|
||||
}
|
||||
}, [serverUrl, t]);
|
||||
|
||||
const handleSaveSelfHosted = useCallback(async () => {
|
||||
setIsSavingSelfHosted(true);
|
||||
try {
|
||||
await invoke<SyncSettings>("save_sync_settings", {
|
||||
syncServerUrl: serverUrl || null,
|
||||
syncToken: token || null,
|
||||
});
|
||||
try {
|
||||
await invoke("restart_sync_service");
|
||||
} catch (e) {
|
||||
console.error("Failed to restart sync service:", e);
|
||||
}
|
||||
showSuccessToast(t("sync.config.settingsSaved"));
|
||||
} catch (error) {
|
||||
console.error("Failed to save sync settings:", error);
|
||||
// Use the structured backend-error translator so the cloud-vs-self-
|
||||
// hosted mutex (`SELF_HOSTED_REQUIRES_LOGOUT`) shows a clear message
|
||||
// instead of the generic "save failed" toast.
|
||||
showErrorToast(translateBackendError(t as never, error));
|
||||
} finally {
|
||||
setIsSavingSelfHosted(false);
|
||||
}
|
||||
}, [serverUrl, token, t]);
|
||||
|
||||
const handleDisconnectSelfHosted = useCallback(async () => {
|
||||
setIsSavingSelfHosted(true);
|
||||
try {
|
||||
await invoke<SyncSettings>("save_sync_settings", {
|
||||
syncServerUrl: null,
|
||||
syncToken: null,
|
||||
});
|
||||
try {
|
||||
await invoke("restart_sync_service");
|
||||
} catch (e) {
|
||||
console.error("Failed to restart sync service:", e);
|
||||
}
|
||||
setServerUrl("");
|
||||
setToken("");
|
||||
setConnectionStatus("unknown");
|
||||
showSuccessToast(t("sync.config.disconnected"));
|
||||
} catch (error) {
|
||||
console.error("Failed to disconnect:", error);
|
||||
showErrorToast(t("sync.config.disconnectFailed"));
|
||||
} finally {
|
||||
setIsSavingSelfHosted(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-2xl flex flex-col">
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<Tabs defaultValue="account">
|
||||
<TabsList
|
||||
className={cn(
|
||||
"w-full",
|
||||
subPage &&
|
||||
"!bg-transparent !p-0 !h-auto !rounded-none justify-start gap-4",
|
||||
)}
|
||||
>
|
||||
<TabsTrigger
|
||||
value="account"
|
||||
className={cn(
|
||||
"flex-1",
|
||||
subPage &&
|
||||
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs",
|
||||
)}
|
||||
>
|
||||
{t("account.tabs.account")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="self-hosted"
|
||||
disabled={selfHostedDisabled}
|
||||
title={
|
||||
selfHostedDisabled
|
||||
? t("account.selfHosted.disabledWhileLoggedIn")
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex-1",
|
||||
subPage &&
|
||||
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs disabled:opacity-50 disabled:hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t("account.tabs.selfHosted")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="account" className="mt-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid place-items-center w-12 h-12 rounded-full bg-accent text-foreground shrink-0">
|
||||
<LuUser className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isLoggedIn && user ? (
|
||||
<>
|
||||
<h2 className="text-base font-semibold truncate">
|
||||
{user.email}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t("account.plan", {
|
||||
plan: user.plan,
|
||||
period: user.planPeriod ?? "—",
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-base font-semibold">
|
||||
{t("account.signedOut")}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t("account.signedOutDescription")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoggedIn && user && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("account.fields.plan")}
|
||||
</p>
|
||||
<p className="mt-0.5 font-medium uppercase">
|
||||
{user.plan}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("account.fields.status")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.subscriptionStatus ?? "—"}</p>
|
||||
</div>
|
||||
{user.teamRole && (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("account.fields.teamRole")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.teamRole}</p>
|
||||
</div>
|
||||
)}
|
||||
{user.planPeriod && (
|
||||
<div className="rounded-md bg-muted/40 border border-border px-3 py-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("account.fields.period")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.planPeriod}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void handleRefresh();
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
>
|
||||
<LuRefreshCw className="w-3 h-3" />
|
||||
{t("account.refresh")}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
isLoading={isLoggingOut}
|
||||
disabled={isRefreshing}
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
>
|
||||
<LuLogOut className="w-3 h-3" />
|
||||
{t("account.logout")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onOpenSignIn}
|
||||
className="h-8 text-xs gap-1.5"
|
||||
>
|
||||
<LuCloud className="w-3 h-3" />
|
||||
{t("account.signIn")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="self-hosted" className="mt-4">
|
||||
{selfHostedDisabled ? (
|
||||
// Defensive: the tab trigger is disabled while the user is
|
||||
// logged in, so this branch shouldn't be reachable via UI —
|
||||
// but if state flips mid-render (e.g. a cloud login finishes
|
||||
// while the tab is open), show the explanation instead of
|
||||
// a silent empty card.
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("account.selfHosted.disabledWhileLoggedIn")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("account.selfHosted.title")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t("account.selfHosted.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="self-hosted-server-url" className="text-xs">
|
||||
{t("sync.serverUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="self-hosted-server-url"
|
||||
type="url"
|
||||
placeholder={t("sync.serverUrlPlaceholder")}
|
||||
value={serverUrl}
|
||||
onChange={(e) => {
|
||||
setServerUrl(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="self-hosted-token" className="text-xs">
|
||||
{t("sync.token")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="self-hosted-token"
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder={t("sync.tokenPlaceholder")}
|
||||
value={token}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="pr-9"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowToken((v) => !v);
|
||||
}}
|
||||
aria-label={
|
||||
showToken
|
||||
? t("common.aria.hideToken")
|
||||
: t("common.aria.showToken")
|
||||
}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken ? (
|
||||
<LuEyeOff className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<LuEye className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("account.selfHosted.connectionStatus")}
|
||||
</span>
|
||||
{connectionStatus === "connected" && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="text-success-foreground bg-success"
|
||||
>
|
||||
{t("sync.status.connected")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "error" && (
|
||||
<Badge variant="destructive">
|
||||
{t("sync.status.error")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "testing" && (
|
||||
<Badge variant="secondary">
|
||||
{t("sync.status.syncing")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "unknown" && (
|
||||
<Badge variant="secondary">
|
||||
{t("account.selfHosted.statusUnknown")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isLoading={isTestingConnection}
|
||||
disabled={!serverUrl || isSavingSelfHosted}
|
||||
onClick={() => void handleTestConnection()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("account.selfHosted.testConnection")}
|
||||
</LoadingButton>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
isLoading={isSavingSelfHosted}
|
||||
disabled={!serverUrl || !token || isTestingConnection}
|
||||
onClick={() => void handleSaveSelfHosted()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</LoadingButton>
|
||||
{hasConfig && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isSavingSelfHosted || isTestingConnection}
|
||||
onClick={() => void handleDisconnectSelfHosted()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("account.selfHosted.disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaExternalLinkAlt, FaTimes } from "react-icons/fa";
|
||||
import { LuCheckCheck } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -19,6 +20,7 @@ export function AppUpdateToast({
|
||||
onDismiss,
|
||||
updateReady = false,
|
||||
}: AppUpdateToastProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleRestartClick = async () => {
|
||||
await onRestart();
|
||||
};
|
||||
@@ -43,10 +45,10 @@ export function AppUpdateToast({
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{updateReady
|
||||
? "Update ready, restart to apply"
|
||||
? t("appUpdate.toast.updateReady")
|
||||
: updateInfo.repo_update
|
||||
? "Update available via package manager"
|
||||
: "Manual download required"}
|
||||
: t("appUpdate.toast.manualDownloadRequired")}
|
||||
</span>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{updateInfo.current_version} → {updateInfo.new_version}
|
||||
@@ -71,7 +73,7 @@ export function AppUpdateToast({
|
||||
className="flex gap-2 items-center text-xs"
|
||||
>
|
||||
<LuCheckCheck className="w-3 h-3" />
|
||||
Restart Now
|
||||
{t("appUpdate.toast.restartNow")}
|
||||
</RippleButton>
|
||||
) : (
|
||||
!updateInfo.repo_update &&
|
||||
@@ -82,7 +84,7 @@ export function AppUpdateToast({
|
||||
className="flex gap-2 items-center text-xs"
|
||||
>
|
||||
<FaExternalLinkAlt className="w-3 h-3" />
|
||||
View Release
|
||||
{t("appUpdate.toast.viewRelease")}
|
||||
</RippleButton>
|
||||
)
|
||||
)}
|
||||
@@ -92,7 +94,7 @@ export function AppUpdateToast({
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
Later
|
||||
{t("appUpdate.toast.later")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -86,6 +86,7 @@ interface CreateProfileDialogProps {
|
||||
ephemeral?: boolean;
|
||||
dnsBlocklist?: string;
|
||||
launchHook?: string;
|
||||
password?: string;
|
||||
}) => Promise<void>;
|
||||
selectedGroupId?: string;
|
||||
crossOsUnlocked?: boolean;
|
||||
@@ -170,6 +171,11 @@ export function CreateProfileDialog({
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [ephemeral, setEphemeral] = useState(false);
|
||||
const [enablePassword, setEnablePassword] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const PASSWORD_MIN_LEN = 8;
|
||||
const [selectedExtensionGroupId, setSelectedExtensionGroupId] =
|
||||
useState<string>();
|
||||
const [extensionGroups, setExtensionGroups] = useState<
|
||||
@@ -370,12 +376,30 @@ export function CreateProfileDialog({
|
||||
const handleCreate = async () => {
|
||||
if (!profileName.trim()) return;
|
||||
|
||||
if (enablePassword && !ephemeral) {
|
||||
if (password.length < PASSWORD_MIN_LEN) {
|
||||
setPasswordError(
|
||||
t("profilePassword.errors.tooShort", { min: PASSWORD_MIN_LEN }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (password !== passwordConfirm) {
|
||||
setPasswordError(t("profilePassword.errors.mismatch"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPasswordError(null);
|
||||
|
||||
setIsCreating(true);
|
||||
|
||||
const isVpnSelection = selectedProxyId?.startsWith("vpn-") ?? false;
|
||||
const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId;
|
||||
const resolvedVpnId =
|
||||
isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined;
|
||||
const passwordToSet =
|
||||
enablePassword && !ephemeral && password.length >= PASSWORD_MIN_LEN
|
||||
? password
|
||||
: undefined;
|
||||
try {
|
||||
if (activeTab === "anti-detect") {
|
||||
// Anti-detect browser - check if Wayfern or Camoufox is selected
|
||||
@@ -403,6 +427,7 @@ export function CreateProfileDialog({
|
||||
ephemeral,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
launchHook: launchHook.trim() || undefined,
|
||||
password: passwordToSet,
|
||||
});
|
||||
} else {
|
||||
// Default to Camoufox
|
||||
@@ -430,6 +455,7 @@ export function CreateProfileDialog({
|
||||
ephemeral,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
launchHook: launchHook.trim() || undefined,
|
||||
password: passwordToSet,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -455,6 +481,7 @@ export function CreateProfileDialog({
|
||||
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
dnsBlocklist: dnsBlocklist || undefined,
|
||||
launchHook: launchHook.trim() || undefined,
|
||||
password: passwordToSet,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -488,6 +515,10 @@ export function CreateProfileDialog({
|
||||
os: getCurrentOS() as WayfernOS, // Reset to current OS
|
||||
});
|
||||
setEphemeral(false);
|
||||
setEnablePassword(false);
|
||||
setPassword("");
|
||||
setPasswordConfirm("");
|
||||
setPasswordError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -537,7 +568,7 @@ export function CreateProfileDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="w-full max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="w-[380px] max-w-[380px] max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>
|
||||
{currentStep === "browser-selection"
|
||||
@@ -709,15 +740,74 @@ export function CreateProfileDialog({
|
||||
<Label htmlFor="ephemeral" className="font-medium">
|
||||
{t("profiles.ephemeral")}
|
||||
</Label>
|
||||
<span className="px-1 py-0.5 text-[10px] leading-none rounded bg-muted text-muted-foreground font-medium">
|
||||
{t("profiles.ephemeralAlpha")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground ml-6">
|
||||
{t("profiles.ephemeralDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Password Option */}
|
||||
{!ephemeral && (
|
||||
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="enable-password"
|
||||
checked={enablePassword}
|
||||
onCheckedChange={(checked) => {
|
||||
setEnablePassword(checked === true);
|
||||
if (checked !== true) {
|
||||
setPassword("");
|
||||
setPasswordConfirm("");
|
||||
setPasswordError(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="enable-password"
|
||||
className="font-medium"
|
||||
>
|
||||
{t("createProfile.passwordProtect.label")}
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground ml-6">
|
||||
{t("createProfile.passwordProtect.description")}
|
||||
</p>
|
||||
{enablePassword && (
|
||||
<div className="ml-6 space-y-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setPasswordError(null);
|
||||
}}
|
||||
placeholder={t(
|
||||
"profilePassword.fields.newPassword",
|
||||
)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => {
|
||||
setPasswordConfirm(e.target.value);
|
||||
setPasswordError(null);
|
||||
}}
|
||||
placeholder={t(
|
||||
"profilePassword.fields.confirm",
|
||||
)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{passwordError && (
|
||||
<p className="text-sm text-destructive">
|
||||
{passwordError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedBrowser === "wayfern" ? (
|
||||
// Wayfern Configuration
|
||||
<div className="space-y-6">
|
||||
@@ -953,7 +1043,7 @@ export function CreateProfileDialog({
|
||||
<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" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fetching available versions...
|
||||
{t("createProfile.version.fetching")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -255,7 +255,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-foreground h-1.5 rounded-full transition-all duration-300"
|
||||
className="bg-foreground h-1.5 rounded-full transition-all duration-150"
|
||||
style={{ width: `${progress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -275,7 +275,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-1 bg-muted rounded-full h-1.5 min-w-0">
|
||||
<div
|
||||
className="bg-foreground h-1.5 rounded-full transition-all duration-300"
|
||||
className="bg-foreground h-1.5 rounded-full transition-all duration-150"
|
||||
style={{
|
||||
width: `${(progress.current / progress.total) * 100}%`,
|
||||
}}
|
||||
@@ -294,7 +294,9 @@ export function UnifiedToast(props: ToastProps) {
|
||||
"completed_files" in progress && (
|
||||
<div className="mt-1">
|
||||
<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
|
||||
{" \u2022 "}
|
||||
{formatBytesCompact(progress.completed_bytes)} /{" "}
|
||||
@@ -349,17 +351,17 @@ export function UnifiedToast(props: ToastProps) {
|
||||
<>
|
||||
{stage === "extracting" && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Extracting browser files... Please do not close the app.
|
||||
{t("browserDownload.toast.extracting")}
|
||||
</p>
|
||||
)}
|
||||
{stage === "verifying" && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Verifying browser files...
|
||||
{t("browserDownload.toast.verifying")}
|
||||
</p>
|
||||
)}
|
||||
{stage === "downloading (twilight rolling release)" && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Downloading rolling release build...
|
||||
{t("browserDownload.toast.downloadingRolling")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Button } from "@/components/ui/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";
|
||||
|
||||
const DEVICE_LINK_URL = "https://donutbrowser.com/auth/link";
|
||||
|
||||
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);
|
||||
const [isOpeningLogin, setIsOpeningLogin] = useState(false);
|
||||
|
||||
const handleOpenLogin = async () => {
|
||||
setIsOpeningLogin(true);
|
||||
try {
|
||||
await invoke("handle_url_open", { url: DEVICE_LINK_URL });
|
||||
} catch (error) {
|
||||
console.error("Failed to open login link:", error);
|
||||
showErrorToast(String(error));
|
||||
} finally {
|
||||
setIsOpeningLogin(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.signInTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sync.cloud.deviceLinkInstructions")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void handleOpenLogin()}
|
||||
disabled={isOpeningLogin}
|
||||
className="w-full gap-1.5"
|
||||
>
|
||||
<LuExternalLink className="w-3.5 h-3.5" />
|
||||
{t("sync.cloud.openLogin")}
|
||||
</Button>
|
||||
<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) {
|
||||
console.error("Failed to load extension groups:", err);
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load extension groups",
|
||||
err instanceof Error ? err.message : t("extensions.loadGroupsFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleAssign = useCallback(async () => {
|
||||
setIsAssigning(true);
|
||||
@@ -79,7 +79,7 @@ export function ExtensionGroupAssignmentDialog({
|
||||
} catch (err) {
|
||||
console.error("Failed to assign extension group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to assign extension group";
|
||||
err instanceof Error ? err.message : t("extensions.assignGroupFailed");
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
|
||||
@@ -50,36 +50,43 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
|
||||
function getSyncStatusDot(
|
||||
item: { sync_enabled?: boolean; last_sync?: number },
|
||||
liveStatus: SyncStatus | undefined,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): { color: string; tooltip: string; animate: boolean } {
|
||||
const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled");
|
||||
|
||||
switch (status) {
|
||||
case "syncing":
|
||||
return { color: "bg-warning", tooltip: "Syncing...", animate: true };
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: t("profileTable.syncTooltipSyncing"),
|
||||
animate: true,
|
||||
};
|
||||
case "synced":
|
||||
return {
|
||||
color: "bg-success",
|
||||
tooltip: item.last_sync
|
||||
? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced",
|
||||
? t("profileTable.syncTooltipSyncedAt", {
|
||||
time: new Date(item.last_sync * 1000).toLocaleString(),
|
||||
})
|
||||
: t("profileTable.syncTooltipSynced"),
|
||||
animate: false,
|
||||
};
|
||||
case "waiting":
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: "Waiting to sync",
|
||||
tooltip: t("profileTable.syncTooltipWaiting"),
|
||||
animate: false,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
color: "bg-destructive",
|
||||
tooltip: "Sync error",
|
||||
tooltip: t("profileTable.syncTooltipError"),
|
||||
animate: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: "bg-muted-foreground",
|
||||
tooltip: "Not synced",
|
||||
tooltip: t("profileTable.syncTooltipNotSynced"),
|
||||
animate: false,
|
||||
};
|
||||
}
|
||||
@@ -89,12 +96,14 @@ interface ExtensionManagementDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
limitedMode: boolean;
|
||||
subPage?: boolean;
|
||||
}
|
||||
|
||||
export function ExtensionManagementDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
limitedMode,
|
||||
subPage,
|
||||
}: ExtensionManagementDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||
@@ -519,18 +528,22 @@ export function ExtensionManagementDialog({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuPuzzle className="w-5 h-5" />
|
||||
{t("extensions.title")}
|
||||
{limitedMode && <ProBadge />}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("extensions.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!subPage && (
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuPuzzle className="w-5 h-5" />
|
||||
{t("extensions.title")}
|
||||
{limitedMode && <ProBadge />}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("extensions.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<ScrollArea className="overflow-y-auto flex-1">
|
||||
<ScrollArea className="overflow-y-auto flex-1 scroll-fade">
|
||||
<div className="relative">
|
||||
{limitedMode && (
|
||||
<>
|
||||
@@ -674,6 +687,7 @@ export function ExtensionManagementDialog({
|
||||
const syncDot = getSyncStatusDot(
|
||||
ext,
|
||||
extSyncStatus[ext.id],
|
||||
t,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
@@ -840,6 +854,7 @@ export function ExtensionManagementDialog({
|
||||
const groupSyncDot = getSyncStatusDot(
|
||||
group,
|
||||
extSyncStatus[group.id],
|
||||
t,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -976,11 +991,13 @@ export function ExtensionManagementDialog({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
{!subPage && (
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -995,7 +1012,7 @@ export function ExtensionManagementDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-lg max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("extensions.editGroup")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -1003,87 +1020,89 @@ export function ExtensionManagementDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<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 && (
|
||||
<ScrollArea className="overflow-y-auto flex-1 -mx-6 px-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("extensions.addToGroup")}</Label>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(extId) => {
|
||||
setEditGroupExtensionIds((prev) => [...prev, extId]);
|
||||
<Label>{t("common.labels.name")}</Label>
|
||||
<Input
|
||||
value={editGroupName}
|
||||
onChange={(e) => {
|
||||
setEditGroupName(e.target.value);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
placeholder={t("extensions.groupNamePlaceholder")}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
{extensions.filter((e) => !editGroupExtensionIds.includes(e.id))
|
||||
.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("extensions.addToGroup")}</Label>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(extId) => {
|
||||
setEditGroupExtensionIds((prev) => [...prev, extId]);
|
||||
}}
|
||||
>
|
||||
<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 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>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -1117,7 +1136,7 @@ export function ExtensionManagementDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-lg max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("extensions.editExtension")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -1125,123 +1144,127 @@ export function ExtensionManagementDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{editingExtension && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("common.labels.name")}</Label>
|
||||
<Input
|
||||
value={editExtensionName}
|
||||
onChange={(e) => {
|
||||
setEditExtensionName(e.target.value);
|
||||
}}
|
||||
placeholder={t("extensions.namePlaceholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleUpdateExtension();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="overflow-y-auto flex-1 -mx-6 px-6">
|
||||
{editingExtension && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("common.labels.name")}</Label>
|
||||
<Input
|
||||
value={editExtensionName}
|
||||
onChange={(e) => {
|
||||
setEditExtensionName(e.target.value);
|
||||
}}
|
||||
placeholder={t("extensions.namePlaceholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleUpdateExtension();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Metadata from manifest.json */}
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
{t("extensions.metadata")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1.5 text-sm">
|
||||
{editingExtension.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{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}
|
||||
{/* Metadata from manifest.json */}
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
{t("extensions.metadata")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1.5 text-sm">
|
||||
{editingExtension.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{t("extensions.version")}
|
||||
</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>{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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
|
||||
@@ -139,7 +139,7 @@ export function GroupBadges({
|
||||
return (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<div className="flex items-center gap-2 px-4.5 py-1.5 text-xs">
|
||||
Loading groups...
|
||||
{t("groups.loading")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -93,12 +93,14 @@ interface GroupManagementDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onGroupManagementComplete: () => void;
|
||||
subPage?: boolean;
|
||||
}
|
||||
|
||||
export function GroupManagementDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onGroupManagementComplete,
|
||||
subPage,
|
||||
}: GroupManagementDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [groups, setGroups] = useState<GroupWithCount[]>([]);
|
||||
@@ -249,14 +251,16 @@ export function GroupManagementDialog({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("groups.management")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("groups.noGroupDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!subPage && (
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("groups.management")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("groups.noGroupDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Create new group button */}
|
||||
@@ -283,7 +287,7 @@ export function GroupManagementDialog({
|
||||
{/* Groups list */}
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
{t("common.buttons.loading")}
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
@@ -418,11 +422,13 @@ export function GroupManagementDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
{!subPage && (
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
+287
-311
@@ -1,245 +1,300 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
"use client";
|
||||
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
|
||||
import {
|
||||
LuCloud,
|
||||
LuPlug,
|
||||
LuPuzzle,
|
||||
LuSearch,
|
||||
LuUsers,
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuChevronLeft, LuChevronRight, LuSearch, LuX } from "react-icons/lu";
|
||||
import { getCurrentOS } from "@/lib/browser-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "./icons/logo";
|
||||
import type { GroupWithCount } from "@/types";
|
||||
import { Button } from "./ui/button";
|
||||
import { CardTitle } from "./ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { Input } from "./ui/input";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
const CLICK_THRESHOLD = 5;
|
||||
const CLICK_WINDOW_MS = 2000;
|
||||
const GRAVITY = 2200;
|
||||
const BOUNCE_DAMPING = 0.6;
|
||||
const INITIAL_HORIZONTAL_SPEED = 350;
|
||||
const SPIN_SPEED = 720;
|
||||
const MIN_BOUNCE_VELOCITY = 60;
|
||||
const LOGO_HIDDEN_KEY = "donut-logo-hidden";
|
||||
const HOLD_MS = 150;
|
||||
const DRAG_THRESHOLD_PX = 3;
|
||||
|
||||
function useLogoEasterEgg() {
|
||||
const clickTimestamps = useRef<number[]>([]);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [wobbleKey, setWobbleKey] = useState(0);
|
||||
const [isFalling, setIsFalling] = useState(false);
|
||||
const [isHidden, setIsHidden] = useState(() => {
|
||||
try {
|
||||
return sessionStorage.getItem(LOGO_HIDDEN_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const logoRef = useRef<HTMLButtonElement>(null);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
const isTextInputTarget = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Element)) return false;
|
||||
const el = target.closest(
|
||||
"input, select, textarea, [contenteditable=''], [contenteditable='true']",
|
||||
);
|
||||
return el !== null;
|
||||
};
|
||||
|
||||
const triggerFall = useCallback(() => {
|
||||
const el = logoRef.current;
|
||||
if (!el || isFalling) return;
|
||||
|
||||
setIsFalling(true);
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const startX = rect.left;
|
||||
const startY = rect.top;
|
||||
const floorY = window.innerHeight;
|
||||
const leftWall = 0;
|
||||
const rightWall = window.innerWidth;
|
||||
|
||||
const clone = el.cloneNode(true) as HTMLElement;
|
||||
clone.style.position = "fixed";
|
||||
clone.style.left = `${startX}px`;
|
||||
clone.style.top = `${startY}px`;
|
||||
clone.style.zIndex = "9999";
|
||||
clone.style.pointerEvents = "none";
|
||||
clone.style.margin = "0";
|
||||
document.body.appendChild(clone);
|
||||
|
||||
el.style.visibility = "hidden";
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let vy = -500;
|
||||
let vx = -INITIAL_HORIZONTAL_SPEED;
|
||||
let rotation = 0;
|
||||
let lastTime = performance.now();
|
||||
|
||||
const animate = (time: number) => {
|
||||
const dt = Math.min((time - lastTime) / 1000, 0.05);
|
||||
lastTime = time;
|
||||
|
||||
vy += GRAVITY * dt;
|
||||
x += vx * dt;
|
||||
y += vy * dt;
|
||||
rotation += SPIN_SPEED * dt * (vx > 0 ? 1 : -1);
|
||||
|
||||
// Floor bounce
|
||||
const currentBottom = startY + y + rect.height;
|
||||
if (currentBottom >= floorY && vy > 0) {
|
||||
y = floorY - startY - rect.height;
|
||||
if (Math.abs(vy) > MIN_BOUNCE_VELOCITY) {
|
||||
vy = -Math.abs(vy) * BOUNCE_DAMPING;
|
||||
} else {
|
||||
vy = -MIN_BOUNCE_VELOCITY * 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Left wall bounce only — right wall lets it fly off screen
|
||||
const currentLeft = startX + x;
|
||||
if (currentLeft <= leftWall && vx < 0) {
|
||||
x = leftWall - startX;
|
||||
vx = Math.abs(vx) * 1.1;
|
||||
}
|
||||
|
||||
clone.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`;
|
||||
|
||||
// Only end when fully off-screen vertically (bounced out the top or flew off bottom somehow)
|
||||
const currentTop = startY + y;
|
||||
const offScreenRight = startX + x > rightWall + 50;
|
||||
const offScreenBottom = currentTop > floorY + 100;
|
||||
const offScreenTop = currentTop + rect.height < -200;
|
||||
|
||||
if (offScreenRight || offScreenBottom || offScreenTop) {
|
||||
clone.remove();
|
||||
try {
|
||||
sessionStorage.setItem(LOGO_HIDDEN_KEY, "1");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setIsHidden(true);
|
||||
setIsFalling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
animFrameRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animFrameRef.current = requestAnimationFrame(animate);
|
||||
}, [isFalling]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isFalling || isHidden) return;
|
||||
|
||||
const now = Date.now();
|
||||
clickTimestamps.current = clickTimestamps.current.filter(
|
||||
(t) => now - t < CLICK_WINDOW_MS,
|
||||
);
|
||||
clickTimestamps.current.push(now);
|
||||
|
||||
if (clickTimestamps.current.length >= CLICK_THRESHOLD) {
|
||||
clickTimestamps.current = [];
|
||||
triggerFall();
|
||||
} else {
|
||||
setWobbleKey((k) => k + 1);
|
||||
}
|
||||
}, [isFalling, isHidden, triggerFall]);
|
||||
|
||||
return {
|
||||
logoRef,
|
||||
isPressed,
|
||||
setIsPressed,
|
||||
wobbleKey,
|
||||
isFalling,
|
||||
isHidden,
|
||||
handleClick,
|
||||
};
|
||||
}
|
||||
const ALL_FILTER_ID = "__all__";
|
||||
|
||||
interface Props {
|
||||
onSettingsDialogOpen: (open: boolean) => void;
|
||||
onProxyManagementDialogOpen: (open: boolean) => void;
|
||||
onGroupManagementDialogOpen: (open: boolean) => void;
|
||||
onImportProfileDialogOpen: (open: boolean) => void;
|
||||
onCreateProfileDialogOpen: (open: boolean) => void;
|
||||
onSyncConfigDialogOpen: (open: boolean) => void;
|
||||
onIntegrationsDialogOpen: (open: boolean) => void;
|
||||
onExtensionManagementDialogOpen: (open: boolean) => void;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
groups: GroupWithCount[];
|
||||
selectedGroupId: string | null;
|
||||
onGroupSelect: (groupId: string) => void;
|
||||
pageTitle?: string;
|
||||
}
|
||||
|
||||
const HomeHeader = ({
|
||||
onSettingsDialogOpen,
|
||||
onProxyManagementDialogOpen,
|
||||
onGroupManagementDialogOpen,
|
||||
onImportProfileDialogOpen,
|
||||
onCreateProfileDialogOpen,
|
||||
onSyncConfigDialogOpen,
|
||||
onIntegrationsDialogOpen,
|
||||
onExtensionManagementDialogOpen,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
groups,
|
||||
selectedGroupId,
|
||||
onGroupSelect,
|
||||
pageTitle,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
logoRef,
|
||||
isPressed,
|
||||
setIsPressed,
|
||||
wobbleKey,
|
||||
isFalling,
|
||||
isHidden,
|
||||
handleClick,
|
||||
} = useLogoEasterEgg();
|
||||
const [platform, setPlatform] = useState<string>("macos");
|
||||
|
||||
useEffect(() => {
|
||||
setPlatform(getCurrentOS());
|
||||
}, []);
|
||||
|
||||
const isMacOS = platform === "macos";
|
||||
const showProfileToolbar = !pageTitle;
|
||||
|
||||
const totalProfiles = useMemo(
|
||||
() => groups.reduce((sum, g) => sum + g.count, 0),
|
||||
[groups],
|
||||
);
|
||||
|
||||
// Press-and-hold drag: any pixel of the sys-bar becomes a drag handle after
|
||||
// HOLD_MS, but quick clicks still reach buttons/inputs underneath.
|
||||
const holdTimeoutRef = useRef<number | null>(null);
|
||||
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const dragStartedRef = useRef(false);
|
||||
const activePointerIdRef = useRef<number | null>(null);
|
||||
const dragRootRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const clearHold = useCallback(() => {
|
||||
if (holdTimeoutRef.current !== null) {
|
||||
window.clearTimeout(holdTimeoutRef.current);
|
||||
holdTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const beginDrag = useCallback(() => {
|
||||
if (dragStartedRef.current) return;
|
||||
dragStartedRef.current = true;
|
||||
clearHold();
|
||||
void getCurrentWindow().startDragging();
|
||||
}, [clearHold]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearHold();
|
||||
};
|
||||
}, [clearHold]);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
if (isTextInputTarget(e.target)) return;
|
||||
|
||||
dragStartedRef.current = false;
|
||||
dragStartRef.current = { x: e.clientX, y: e.clientY };
|
||||
activePointerIdRef.current = e.pointerId;
|
||||
|
||||
clearHold();
|
||||
holdTimeoutRef.current = window.setTimeout(() => {
|
||||
holdTimeoutRef.current = null;
|
||||
beginDrag();
|
||||
}, HOLD_MS);
|
||||
},
|
||||
[beginDrag, clearHold],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
dragStartedRef.current ||
|
||||
dragStartRef.current === null ||
|
||||
activePointerIdRef.current !== e.pointerId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const dx = e.clientX - dragStartRef.current.x;
|
||||
const dy = e.clientY - dragStartRef.current.y;
|
||||
if (Math.hypot(dx, dy) > DRAG_THRESHOLD_PX) {
|
||||
beginDrag();
|
||||
}
|
||||
},
|
||||
[beginDrag],
|
||||
);
|
||||
|
||||
const handlePointerEnd = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (activePointerIdRef.current !== e.pointerId) return;
|
||||
clearHold();
|
||||
dragStartRef.current = null;
|
||||
activePointerIdRef.current = null;
|
||||
dragStartedRef.current = false;
|
||||
},
|
||||
[clearHold],
|
||||
);
|
||||
|
||||
// Horizontal scroll fades for the group filter strip — when the user
|
||||
// has more groups than fit, the right edge fades to hint at overflow.
|
||||
const groupsScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [groupsFadeLeft, setGroupsFadeLeft] = useState(false);
|
||||
const [groupsFadeRight, setGroupsFadeRight] = useState(false);
|
||||
useEffect(() => {
|
||||
const el = groupsScrollRef.current;
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
setGroupsFadeLeft(el.scrollLeft > 1);
|
||||
setGroupsFadeRight(el.scrollWidth - el.clientWidth - el.scrollLeft > 1);
|
||||
};
|
||||
update();
|
||||
el.addEventListener("scroll", update, { passive: true });
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
el.removeEventListener("scroll", update);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isWindows = platform === "windows";
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<div className="flex gap-3 items-center">
|
||||
{!isHidden ? (
|
||||
<button
|
||||
ref={logoRef}
|
||||
type="button"
|
||||
className="p-1 cursor-pointer select-none"
|
||||
onClick={handleClick}
|
||||
onPointerDown={() => {
|
||||
setIsPressed(true);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
setIsPressed(false);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setIsPressed(false);
|
||||
<div
|
||||
ref={dragRootRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
className={cn(
|
||||
"flex items-center gap-2 h-11 pl-3 border-b border-border bg-card select-none",
|
||||
// Windows: WindowDragArea renders two 44px native-style controls
|
||||
// (minimize + close) fixed at top-right with z-50, total 88px wide.
|
||||
// Reserve 100px on the right edge so the "+ New" button and search
|
||||
// input clear them with a few pixels of breathing room — issues
|
||||
// #358, #361, #362 all reported the same overlap before this fix.
|
||||
isWindows ? "pr-[100px]" : "pr-3",
|
||||
)}
|
||||
>
|
||||
{isMacOS && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex items-center gap-[7px] mr-1 shrink-0"
|
||||
>
|
||||
{/* Reserve space for the macOS native traffic lights — the OS draws
|
||||
the colored buttons here through the transparent titlebar. */}
|
||||
<div className="w-[11px] h-[11px] rounded-full" />
|
||||
<div className="w-[11px] h-[11px] rounded-full" />
|
||||
<div className="w-[11px] h-[11px] rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pageTitle ? (
|
||||
<span className="text-xs font-semibold text-card-foreground ml-2">
|
||||
{pageTitle}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{showProfileToolbar && (
|
||||
<div className="relative flex-1 min-w-0 flex items-center">
|
||||
{groupsFadeLeft && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("header.scrollGroupsLeft")}
|
||||
onClick={() => {
|
||||
const el = groupsScrollRef.current;
|
||||
if (el)
|
||||
el.scrollBy({
|
||||
left: -el.clientWidth * 0.6,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-5 h-5 rounded-full bg-card/90 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors shadow-sm"
|
||||
>
|
||||
<LuChevronLeft className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
ref={groupsScrollRef}
|
||||
className="flex items-center gap-3 ml-2 overflow-x-auto scroll-smooth [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
style={{
|
||||
paddingLeft: groupsFadeLeft ? 22 : 0,
|
||||
paddingRight: groupsFadeRight ? 22 : 0,
|
||||
}}
|
||||
>
|
||||
<Logo
|
||||
key={wobbleKey}
|
||||
className={cn(
|
||||
"w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110",
|
||||
isPressed && "scale-90",
|
||||
!isFalling &&
|
||||
!isPressed &&
|
||||
wobbleKey > 0 &&
|
||||
"animate-[wiggle_0.3s_ease-in-out]",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="p-1 w-10 h-10" />
|
||||
)}
|
||||
<CardTitle>Donut</CardTitle>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="relative">
|
||||
{/* "All" filter — shows every profile regardless of group. */}
|
||||
{(() => {
|
||||
const active = selectedGroupId === ALL_FILTER_ID;
|
||||
return (
|
||||
<button
|
||||
key="__all__"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onGroupSelect(ALL_FILTER_ID);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 h-7 px-1 text-xs transition-colors duration-100 shrink-0",
|
||||
active
|
||||
? "text-foreground font-medium"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span>{t("groups.all")}</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{totalProfiles}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
{groups.map((group) => {
|
||||
const active = selectedGroupId === group.id;
|
||||
const label =
|
||||
group.id === "default" ? t("groups.defaultGroup") : group.name;
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onGroupSelect(active ? ALL_FILTER_ID : group.id);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 h-7 px-1 text-xs transition-colors duration-100 shrink-0",
|
||||
active
|
||||
? "text-foreground font-medium"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{group.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{groupsFadeRight && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("header.scrollGroupsRight")}
|
||||
onClick={() => {
|
||||
const el = groupsScrollRef.current;
|
||||
if (el)
|
||||
el.scrollBy({
|
||||
left: el.clientWidth * 0.6,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-5 h-5 rounded-full bg-card/90 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors shadow-sm"
|
||||
>
|
||||
<LuChevronRight className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showProfileToolbar && <div className="flex-1" />}
|
||||
|
||||
{showProfileToolbar && (
|
||||
<div className="relative shrink-0">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("header.searchPlaceholder")}
|
||||
@@ -247,122 +302,43 @@ const HomeHeader = ({
|
||||
onChange={(e) => {
|
||||
onSearchQueryChange(e.target.value);
|
||||
}}
|
||||
className="pr-8 pl-10 w-48"
|
||||
className="pr-7 pl-8 w-52 h-7 text-xs"
|
||||
/>
|
||||
<LuSearch className="absolute left-3 top-1/2 w-4 h-4 transform -translate-y-1/2 text-muted-foreground" />
|
||||
{searchQuery && (
|
||||
<LuSearch className="absolute left-2.5 top-1/2 w-3.5 h-3.5 transform -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||
{searchQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSearchQueryChange("");
|
||||
}}
|
||||
className="absolute right-2 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
|
||||
className="absolute right-1.5 top-1/2 p-0.5 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
|
||||
aria-label={t("header.clearSearch")}
|
||||
>
|
||||
<LuX className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
<LuX className="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex gap-2 items-center h-[36px]"
|
||||
>
|
||||
<GoKebabHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("header.moreActions")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onSettingsDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<GoGear className="mr-2 w-4 h-4" />
|
||||
{t("header.menu.settings")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onProxyManagementDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<FiWifi className="mr-2 w-4 h-4" />
|
||||
{t("header.menu.proxies")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onGroupManagementDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<LuUsers className="mr-2 w-4 h-4" />
|
||||
{t("header.menu.groups")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onExtensionManagementDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<LuPuzzle className="mr-2 w-4 h-4" />
|
||||
{t("header.menu.extensions")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onSyncConfigDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<LuCloud className="mr-2 w-4 h-4" />
|
||||
{t("header.menu.syncService")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onIntegrationsDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<LuPlug className="mr-2 w-4 h-4" />
|
||||
{t("header.menu.integrations")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onImportProfileDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<FaDownload className="mr-2 w-4 h-4" />
|
||||
{t("header.menu.importProfile")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{showProfileToolbar && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<span className="shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onCreateProfileDialogOpen(true);
|
||||
}}
|
||||
className="flex gap-2 items-center h-[36px]"
|
||||
className="flex gap-1.5 items-center h-7 px-2.5 text-xs"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
<GoPlus className="w-3.5 h-3.5" />
|
||||
{t("header.newProfile")}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
arrowOffset={-8}
|
||||
style={{ transform: "translateX(-8px)" }}
|
||||
>
|
||||
{t("header.createProfile")}
|
||||
</TooltipContent>
|
||||
<TooltipContent>{t("header.createProfile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
@@ -30,6 +29,7 @@ import { WayfernConfigForm } from "@/components/wayfern-config-form";
|
||||
import { useBrowserSupport } from "@/hooks/use-browser-support";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CamoufoxConfig, DetectedProfile, WayfernConfig } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -43,12 +43,14 @@ interface ImportProfileDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
crossOsUnlocked?: boolean;
|
||||
subPage?: boolean;
|
||||
}
|
||||
|
||||
export function ImportProfileDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
crossOsUnlocked,
|
||||
subPage,
|
||||
}: ImportProfileDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>(
|
||||
@@ -292,11 +294,13 @@ export function ImportProfileDialog({
|
||||
}, [isOpen, loadDetectedProfiles]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>{t("importProfile.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!subPage && (
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>{t("importProfile.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
|
||||
{currentStep === "select" && (
|
||||
@@ -543,9 +547,9 @@ export function ImportProfileDialog({
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t("importProfile.importedAsPrefix")}{" "}
|
||||
<strong>{getBrowserDisplayName(currentMappedBrowser)}</strong>{" "}
|
||||
{t("importProfile.importedAsSuffix")}
|
||||
{t("importProfile.importedAs", {
|
||||
browser: getBrowserDisplayName(currentMappedBrowser),
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -600,12 +604,19 @@ export function ImportProfileDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 flex gap-2 items-center justify-end",
|
||||
subPage ? "pt-2 border-t border-border" : undefined,
|
||||
)}
|
||||
>
|
||||
{currentStep === "select" ? (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
{!subPage && (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
)}
|
||||
<RippleButton
|
||||
disabled={!canProceedToNext}
|
||||
onClick={() => {
|
||||
@@ -635,7 +646,7 @@ export function ImportProfileDialog({
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -36,11 +36,13 @@ interface McpConfig {
|
||||
interface IntegrationsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
subPage?: boolean;
|
||||
}
|
||||
|
||||
export function IntegrationsDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
subPage,
|
||||
}: IntegrationsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [settings, setSettings] = useState<AppSettings>({
|
||||
@@ -206,11 +208,14 @@ export function IntegrationsDialog({
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
subPage={subPage}
|
||||
>
|
||||
<DialogContent className="max-w-xl max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("integrations.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!subPage && (
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("integrations.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto flex-1 min-h-0">
|
||||
<Tabs defaultValue="api" className="w-full">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BsCamera, BsMic } from "react-icons/bs";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
@@ -21,7 +21,14 @@ interface PermissionDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
permissionType: PermissionType;
|
||||
onPermissionGranted?: () => void;
|
||||
/**
|
||||
* Fired when the displayed permission becomes granted. The just-granted
|
||||
* type is passed through so the parent can act optimistically — its own
|
||||
* usePermissions instance polls on a 5 s cadence and would otherwise be
|
||||
* stale right after the macOS system prompt is accepted, leaving the
|
||||
* dialog open in a confusing state.
|
||||
*/
|
||||
onPermissionGranted?: (justGranted: PermissionType) => void;
|
||||
}
|
||||
|
||||
export function PermissionDialog({
|
||||
@@ -32,6 +39,7 @@ export function PermissionDialog({
|
||||
}: PermissionDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isRequesting, setIsRequesting] = useState(false);
|
||||
const [isWaitingForGrant, setIsWaitingForGrant] = useState(false);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const {
|
||||
requestPermission,
|
||||
@@ -57,12 +65,68 @@ export function PermissionDialog({
|
||||
? isMicrophoneAccessGranted
|
||||
: isCameraAccessGranted;
|
||||
|
||||
// Auto-close dialog when permission is granted
|
||||
// Mirror the latest permission state into a ref so the deferred timeout
|
||||
// callback can read it without being recreated on every state change.
|
||||
const isCurrentPermissionGrantedRef = useRef(isCurrentPermissionGranted);
|
||||
useEffect(() => {
|
||||
if (isCurrentPermissionGranted && isOpen) {
|
||||
onPermissionGranted?.();
|
||||
isCurrentPermissionGrantedRef.current = isCurrentPermissionGranted;
|
||||
}, [isCurrentPermissionGranted]);
|
||||
|
||||
// When the permission becomes granted, fire a success toast and let the
|
||||
// parent decide what to do next (progress to the other permission, or close).
|
||||
// We deliberately do NOT keep the dialog around to show a "Done" state —
|
||||
// the toast is the confirmation, and the dialog closes immediately.
|
||||
// Use a ref to ensure we only fire the toast once per grant transition.
|
||||
const grantedToastFiredForRef = useRef<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) => {
|
||||
switch (type) {
|
||||
@@ -95,11 +159,25 @@ export function PermissionDialog({
|
||||
setIsRequesting(true);
|
||||
try {
|
||||
await requestPermission(permissionType);
|
||||
showSuccessToast(
|
||||
permissionType === "microphone"
|
||||
? t("permissionDialog.requestSuccessMicrophone")
|
||||
: t("permissionDialog.requestSuccessCamera"),
|
||||
);
|
||||
// The macOS permission poll runs every 5 s, so the new state can take
|
||||
// a moment to surface. Keep the grant button in its busy state for
|
||||
// that window so the user has clear feedback, and notify them if the
|
||||
// grant still hasn't landed by the end.
|
||||
setIsWaitingForGrant(true);
|
||||
if (waitTimeoutRef.current) {
|
||||
clearTimeout(waitTimeoutRef.current);
|
||||
}
|
||||
waitTimeoutRef.current = setTimeout(() => {
|
||||
waitTimeoutRef.current = null;
|
||||
setIsWaitingForGrant(false);
|
||||
if (!isCurrentPermissionGrantedRef.current) {
|
||||
showErrorToast(
|
||||
permissionType === "microphone"
|
||||
? t("permissionDialog.stillNotGrantedMicrophone")
|
||||
: t("permissionDialog.stillNotGrantedCamera"),
|
||||
);
|
||||
}
|
||||
}, 5000);
|
||||
} catch (error) {
|
||||
console.error("Failed to request permission:", error);
|
||||
showErrorToast(t("permissionDialog.requestFailed"));
|
||||
@@ -129,16 +207,6 @@ export function PermissionDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<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 && (
|
||||
<div className="p-3 bg-warning/10 rounded-lg">
|
||||
<p className="text-sm text-warning">
|
||||
@@ -151,15 +219,17 @@ export function PermissionDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
{isCurrentPermissionGranted
|
||||
? t("permissionDialog.doneButton")
|
||||
: t("permissionDialog.cancelButton")}
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="min-w-24"
|
||||
>
|
||||
{t("permissionDialog.cancelButton")}
|
||||
</RippleButton>
|
||||
|
||||
{!isCurrentPermissionGranted && (
|
||||
<LoadingButton
|
||||
isLoading={isRequesting}
|
||||
isLoading={isRequesting || isWaitingForGrant}
|
||||
onClick={() => {
|
||||
handleRequestPermission().catch((err: unknown) => {
|
||||
console.error(err);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
@@ -23,7 +24,9 @@ import {
|
||||
LuCookie,
|
||||
LuInfo,
|
||||
LuLock,
|
||||
LuPlay,
|
||||
LuPuzzle,
|
||||
LuSquare,
|
||||
LuTrash2,
|
||||
LuTriangleAlert,
|
||||
LuUsers,
|
||||
@@ -51,7 +54,6 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -68,12 +70,12 @@ import {
|
||||
import { useBrowserState } from "@/hooks/use-browser-state";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useScrollFade } from "@/hooks/use-scroll-fade";
|
||||
import { useTableSorting } from "@/hooks/use-table-sorting";
|
||||
import { useTeamLocks } from "@/hooks/use-team-locks";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import {
|
||||
getBrowserDisplayName,
|
||||
getCurrentOS,
|
||||
getOSDisplayName,
|
||||
getProfileIcon,
|
||||
isCrossOsProfile,
|
||||
@@ -83,6 +85,7 @@ import { trimName } from "@/lib/name-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
BrowserProfile,
|
||||
ExtensionGroup,
|
||||
LocationItem,
|
||||
ProxyCheckResult,
|
||||
StoredProxy,
|
||||
@@ -154,6 +157,15 @@ interface TableMeta {
|
||||
vpnId: string | null,
|
||||
) => void | Promise<void>;
|
||||
|
||||
// Extension groups (for Ext column lookup)
|
||||
extensionGroups: ExtensionGroup[];
|
||||
|
||||
// Click handlers for inline Ext / DNS cell editing
|
||||
onAssignExtensionGroup?: (profileIds: string[]) => void;
|
||||
setDnsBlocklistProfile: React.Dispatch<
|
||||
React.SetStateAction<BrowserProfile | null>
|
||||
>;
|
||||
|
||||
// Selection helpers
|
||||
isProfileSelected: (id: string) => boolean;
|
||||
handleToggleAll: (checked: boolean) => void;
|
||||
@@ -298,6 +310,187 @@ function getProfileSyncStatusDot(
|
||||
}
|
||||
}
|
||||
|
||||
// Inline extension-group dropdown for the Ext column. Matches the
|
||||
// proxy column's Popover-style picker — no nested dialog.
|
||||
function ExtCell({
|
||||
profile,
|
||||
meta,
|
||||
}: {
|
||||
profile: BrowserProfile;
|
||||
meta: TableMeta;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const groupId = profile.extension_group_id ?? null;
|
||||
const group = groupId
|
||||
? meta.extensionGroups.find((g) => g.id === groupId)
|
||||
: undefined;
|
||||
const label = group?.name ?? meta.t("profiles.table.extDefault");
|
||||
|
||||
const onPick = async (nextId: string | null) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await invoke("assign_extension_group_to_profile", {
|
||||
profileId: profile.id,
|
||||
extensionGroupId: nextId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to assign extension group:", err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-1.5 h-7 px-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded transition-colors duration-100 w-full text-left disabled:opacity-50"
|
||||
>
|
||||
<LuPuzzle className="w-3 h-3 shrink-0" />
|
||||
<span className="truncate flex-1" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
<LuChevronDown className="w-3 h-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={meta.t("profiles.table.extSearch")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{meta.t("profiles.table.extEmpty")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__default__"
|
||||
onSelect={() => {
|
||||
void onPick(null);
|
||||
}}
|
||||
>
|
||||
{groupId === null && <LuCheck className="mr-2 w-3.5 h-3.5" />}
|
||||
<span className={groupId === null ? "" : "ml-5"}>
|
||||
{meta.t("profiles.table.extDefault")}
|
||||
</span>
|
||||
</CommandItem>
|
||||
{meta.extensionGroups.map((g) => (
|
||||
<CommandItem
|
||||
key={g.id}
|
||||
value={g.name}
|
||||
onSelect={() => {
|
||||
void onPick(g.id);
|
||||
}}
|
||||
>
|
||||
{groupId === g.id && <LuCheck className="mr-2 w-3.5 h-3.5" />}
|
||||
<span className={groupId === g.id ? "" : "ml-5"}>
|
||||
{g.name}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline DNS blocklist dropdown — same Popover/Command pattern as Ext.
|
||||
function DnsCell({
|
||||
profile,
|
||||
meta,
|
||||
}: {
|
||||
profile: BrowserProfile;
|
||||
meta: TableMeta;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const level = profile.dns_blocklist ?? null;
|
||||
// Backend levels are: light, normal, pro, pro_plus, ultimate (+ null).
|
||||
// Keep the list ordered from least to most restrictive.
|
||||
const LEVELS: { value: string; labelKey: string }[] = [
|
||||
{ value: "light", labelKey: "dnsBlocklist.light" },
|
||||
{ value: "normal", labelKey: "dnsBlocklist.normal" },
|
||||
{ value: "pro", labelKey: "dnsBlocklist.pro" },
|
||||
{ value: "pro_plus", labelKey: "dnsBlocklist.proPlus" },
|
||||
{ value: "ultimate", labelKey: "dnsBlocklist.ultimate" },
|
||||
];
|
||||
|
||||
const onPick = async (nextLevel: string | null) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await invoke("update_profile_dns_blocklist", {
|
||||
profileId: profile.id,
|
||||
dnsBlocklist: nextLevel,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to update DNS blocklist:", err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-1.5 h-7 px-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded transition-colors duration-100 w-full text-left disabled:opacity-50"
|
||||
title={
|
||||
level
|
||||
? meta.t("profiles.table.dnsLevel", { level })
|
||||
: meta.t("dnsBlocklist.none")
|
||||
}
|
||||
>
|
||||
<FiWifi className="w-3 h-3 shrink-0" />
|
||||
<span className="flex-1 truncate uppercase text-[10px] font-mono tracking-wide">
|
||||
{level ?? "—"}
|
||||
</span>
|
||||
<LuChevronDown className="w-3 h-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-0" align="start">
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() => {
|
||||
void onPick(null);
|
||||
}}
|
||||
>
|
||||
{level === null && <LuCheck className="mr-2 w-3.5 h-3.5" />}
|
||||
<span className={level === null ? "" : "ml-5"}>
|
||||
{meta.t("dnsBlocklist.none")}
|
||||
</span>
|
||||
</CommandItem>
|
||||
{LEVELS.map((l) => (
|
||||
<CommandItem
|
||||
key={l.value}
|
||||
value={l.value}
|
||||
onSelect={() => {
|
||||
void onPick(l.value);
|
||||
}}
|
||||
>
|
||||
{level === l.value && (
|
||||
<LuCheck className="mr-2 w-3.5 h-3.5" />
|
||||
)}
|
||||
<span className={level === l.value ? "" : "ml-5"}>
|
||||
{meta.t(l.labelKey)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const TagsCell = React.memo<{
|
||||
profile: BrowserProfile;
|
||||
isDisabled: boolean;
|
||||
@@ -536,7 +729,11 @@ const TagsCell = React.memo<{
|
||||
onChange={(opts) => void handleChange(opts)}
|
||||
creatable
|
||||
selectFirstItem={false}
|
||||
placeholder={effectiveTags.length === 0 ? "Add tags" : ""}
|
||||
placeholder={
|
||||
effectiveTags.length === 0
|
||||
? translate("profileTable.addTagsPlaceholder")
|
||||
: ""
|
||||
}
|
||||
className={cn(
|
||||
"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!",
|
||||
@@ -850,6 +1047,9 @@ interface ProfilesDataTableProps {
|
||||
}
|
||||
| undefined;
|
||||
onLaunchWithSync?: (profile: BrowserProfile) => void;
|
||||
onSetPassword?: (profile: BrowserProfile) => void;
|
||||
onChangePassword?: (profile: BrowserProfile) => void;
|
||||
onRemovePassword?: (profile: BrowserProfile) => void;
|
||||
}
|
||||
|
||||
export function ProfilesDataTable({
|
||||
@@ -879,6 +1079,9 @@ export function ProfilesDataTable({
|
||||
syncUnlocked = false,
|
||||
getProfileSyncInfo,
|
||||
onLaunchWithSync,
|
||||
onSetPassword,
|
||||
onChangePassword,
|
||||
onRemovePassword,
|
||||
}: ProfilesDataTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
||||
@@ -903,6 +1106,13 @@ export function ProfilesDataTable({
|
||||
}
|
||||
setRowSelection(newSelection);
|
||||
prevSelectedProfilesRef.current = selectedProfiles;
|
||||
// When the parent clears the selection (e.g. after a bulk action like
|
||||
// delete / move-to-group), collapse the checkbox column back to icons.
|
||||
// Otherwise the row checkboxes stay visible and only revert after the
|
||||
// user clicks one — which the per-checkbox handler resets.
|
||||
if (selectedProfiles.length === 0) {
|
||||
setShowCheckboxes(false);
|
||||
}
|
||||
}
|
||||
}, [selectedProfiles]);
|
||||
|
||||
@@ -1006,6 +1216,36 @@ export function ProfilesDataTable({
|
||||
// Country proxy creation state (for inline proxy creation in dropdown)
|
||||
const [countries, setCountries] = React.useState<LocationItem[]>([]);
|
||||
const [countriesLoaded, setCountriesLoaded] = React.useState(false);
|
||||
|
||||
// Extension groups for the Ext column lookup. Refreshed when the
|
||||
// backend emits 'extensions-changed' (group rename/create/delete).
|
||||
const [extensionGroups, setExtensionGroups] = React.useState<
|
||||
ExtensionGroup[]
|
||||
>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
let unlisten: (() => void) | undefined;
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await invoke<ExtensionGroup[]>("list_extension_groups");
|
||||
if (mounted) setExtensionGroups(data);
|
||||
} catch (e) {
|
||||
console.error("Failed to load extension groups:", e);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
void listen("extensions-changed", () => {
|
||||
void load();
|
||||
}).then((u) => {
|
||||
if (mounted) unlisten = u;
|
||||
else u();
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
const canCreateLocationProxy = false;
|
||||
|
||||
const loadCountries = React.useCallback(async () => {
|
||||
@@ -1535,6 +1775,11 @@ export function ProfilesDataTable({
|
||||
vpnOverrides,
|
||||
handleVpnSelection,
|
||||
|
||||
// Extension groups
|
||||
extensionGroups,
|
||||
onAssignExtensionGroup,
|
||||
setDnsBlocklistProfile,
|
||||
|
||||
// Selection helpers
|
||||
isProfileSelected: (id: string) => selectedProfiles.includes(id),
|
||||
handleToggleAll,
|
||||
@@ -1626,6 +1871,8 @@ export function ProfilesDataTable({
|
||||
vpnConfigs,
|
||||
vpnOverrides,
|
||||
handleVpnSelection,
|
||||
extensionGroups,
|
||||
onAssignExtensionGroup,
|
||||
handleToggleAll,
|
||||
handleCheckboxChange,
|
||||
handleIconClick,
|
||||
@@ -1724,7 +1971,7 @@ export function ProfilesDataTable({
|
||||
>
|
||||
<span className="w-4 h-4 group">
|
||||
<OsIcon className="w-4 h-4 text-muted-foreground group-hover:hidden" />
|
||||
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-200" />
|
||||
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-150" />
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
@@ -1833,7 +2080,7 @@ export function ProfilesDataTable({
|
||||
{IconComponent && (
|
||||
<IconComponent className="w-4 h-4 group-hover:hidden" />
|
||||
)}
|
||||
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-200" />
|
||||
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-150" />
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
@@ -1842,10 +2089,11 @@ export function ProfilesDataTable({
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
size: 28,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 48,
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
@@ -1963,11 +2211,18 @@ export function ProfilesDataTable({
|
||||
variant={buttonVariant}
|
||||
size="sm"
|
||||
disabled={!canLaunch || isLaunching || isStopping}
|
||||
aria-label={
|
||||
isRunning
|
||||
? meta.t("profiles.actions.stop")
|
||||
: meta.t("profiles.actions.launch")
|
||||
}
|
||||
className={cn(
|
||||
"min-w-[70px] h-7",
|
||||
"h-7 w-7 p-0 grid place-items-center",
|
||||
!canLaunch && "opacity-50 cursor-not-allowed",
|
||||
canLaunch && "cursor-pointer",
|
||||
isFollower && "border-accent",
|
||||
isRunning &&
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20",
|
||||
)}
|
||||
onClick={() =>
|
||||
isRunning
|
||||
@@ -1976,13 +2231,11 @@ export function ProfilesDataTable({
|
||||
}
|
||||
>
|
||||
{isLaunching || isStopping ? (
|
||||
<div className="flex gap-1 items-center">
|
||||
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
|
||||
</div>
|
||||
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
|
||||
) : isRunning ? (
|
||||
"Stop"
|
||||
<LuSquare className="w-3.5 h-3.5 fill-current" />
|
||||
) : (
|
||||
"Launch"
|
||||
<LuPlay className="w-3.5 h-3.5 fill-current" />
|
||||
)}
|
||||
</RippleButton>
|
||||
</span>
|
||||
@@ -1999,7 +2252,9 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
size: 130,
|
||||
header: ({ column, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -2008,7 +2263,7 @@ export function ProfilesDataTable({
|
||||
}}
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
>
|
||||
Name
|
||||
{meta.t("common.labels.name")}
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
<LuChevronUp className="ml-2 w-4 h-4" />
|
||||
) : column.getIsSorted() === "desc" ? (
|
||||
@@ -2070,11 +2325,15 @@ export function ProfilesDataTable({
|
||||
|
||||
const display =
|
||||
name.length < 14 ? (
|
||||
<div className="font-medium text-left leading-none">{name}</div>
|
||||
<div className="font-medium text-left leading-none truncate">
|
||||
{name}
|
||||
</div>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="leading-none">{trimName(name, 14)}</span>
|
||||
<span className="leading-none block truncate">
|
||||
{trimName(name, 14)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{name}</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -2092,11 +2351,11 @@ export function ProfilesDataTable({
|
||||
const isLocked = meta.isProfileLockedByAnother(profile.id);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1.5 min-w-0 max-w-full overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-2 py-1 mr-auto text-left bg-transparent rounded border-none w-30 h-6",
|
||||
"px-2 py-1 mr-auto text-left bg-transparent rounded border-none h-6 min-w-0 max-w-full overflow-hidden",
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
@@ -2137,7 +2396,11 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
header: "Tags",
|
||||
size: 100,
|
||||
header: ({ table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
return meta.t("profileTable.tagsHeader");
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
@@ -2166,7 +2429,11 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
id: "note",
|
||||
header: "Note",
|
||||
size: 80,
|
||||
header: ({ table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
return meta.t("profileTable.noteHeader");
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
@@ -2193,7 +2460,11 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
id: "proxy",
|
||||
header: "Proxy / VPN",
|
||||
size: 110,
|
||||
header: ({ table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
return meta.t("profiles.table.proxy");
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
@@ -2231,7 +2502,7 @@ export function ProfilesDataTable({
|
||||
? effectiveVpn.name
|
||||
: effectiveProxy
|
||||
? effectiveProxy.name
|
||||
: "Not Selected";
|
||||
: meta.t("profiles.table.notSelected");
|
||||
const vpnBadge = effectiveVpn ? "WG" : null;
|
||||
const tooltipText = hasAssignment ? displayName : null;
|
||||
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
|
||||
@@ -2248,17 +2519,19 @@ export function ProfilesDataTable({
|
||||
(snapshot?.current_bytes_received ?? 0);
|
||||
|
||||
return (
|
||||
<BandwidthMiniChart
|
||||
key={`${profile.id}-${snapshot?.last_update ?? 0}-${bandwidthData.length}`}
|
||||
data={bandwidthData}
|
||||
currentBandwidth={currentBandwidth}
|
||||
onClick={() => meta.onOpenTrafficDialog?.(profile.id)}
|
||||
/>
|
||||
<div className="overflow-hidden min-w-0">
|
||||
<BandwidthMiniChart
|
||||
key={`${profile.id}-${snapshot?.last_update ?? 0}-${bandwidthData.length}`}
|
||||
data={bandwidthData}
|
||||
currentBandwidth={currentBandwidth}
|
||||
onClick={() => meta.onOpenTrafficDialog?.(profile.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex overflow-hidden gap-2 items-center min-w-0">
|
||||
<Popover
|
||||
open={isSelectorOpen}
|
||||
onOpenChange={(open) => {
|
||||
@@ -2372,7 +2645,7 @@ export function ProfilesDataTable({
|
||||
))}
|
||||
</CommandGroup>
|
||||
{meta.vpnConfigs.length > 0 && (
|
||||
<CommandGroup heading="VPNs">
|
||||
<CommandGroup heading={t("profileTable.vpnsHeading")}>
|
||||
{meta.vpnConfigs.map((vpn) => (
|
||||
<CommandItem
|
||||
key={vpn.id}
|
||||
@@ -2405,7 +2678,9 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
{meta.canCreateLocationProxy &&
|
||||
meta.countries.length > 0 && (
|
||||
<CommandGroup heading="Create by country">
|
||||
<CommandGroup
|
||||
heading={t("profileTable.createByCountryHeading")}
|
||||
>
|
||||
{meta.countries
|
||||
.filter(
|
||||
(c) =>
|
||||
@@ -2462,10 +2737,36 @@ export function ProfilesDataTable({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ext",
|
||||
size: 95,
|
||||
header: ({ table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
return meta.t("profiles.table.ext");
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
return <ExtCell profile={profile} meta={meta} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dns",
|
||||
size: 95,
|
||||
header: ({ table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
return meta.t("profiles.table.dns");
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
return <DnsCell profile={profile} meta={meta} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sync",
|
||||
header: "",
|
||||
size: 24,
|
||||
size: 28,
|
||||
cell: ({ row, table }) => {
|
||||
const profile = row.original;
|
||||
const meta = table.options.meta as TableMeta;
|
||||
@@ -2489,7 +2790,7 @@ export function ProfilesDataTable({
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex justify-center items-center w-3 h-3">
|
||||
<span className="flex justify-center items-center h-9 w-full">
|
||||
{dot.encrypted ? (
|
||||
<LuLock
|
||||
className={`w-3 h-3 ${dot.color.replace("bg-", "text-")}${dot.animate ? " animate-pulse" : ""}`}
|
||||
@@ -2508,16 +2809,16 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
size: 40,
|
||||
size: 32,
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex justify-end items-center">
|
||||
<div className="flex justify-end items-center h-9 w-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-0 w-8 h-8"
|
||||
className="p-0 w-7 h-7"
|
||||
disabled={!meta.isClient}
|
||||
onClick={() => {
|
||||
setProfileForInfoDialog(profile);
|
||||
@@ -2559,98 +2860,136 @@ export function ProfilesDataTable({
|
||||
meta: tableMeta,
|
||||
});
|
||||
|
||||
const platform = getCurrentOS();
|
||||
const scrollParentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const sortedRows = table.getRowModel().rows;
|
||||
useScrollFade(scrollParentRef);
|
||||
|
||||
// Compact 36px row from the redesign spec; estimateSize must match the
|
||||
// actual rendered row height or virtualizer placement drifts under scroll.
|
||||
const ROW_HEIGHT = 36;
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: sortedRows.length,
|
||||
getScrollElement: () => scrollParentRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
|
||||
const paddingBottom =
|
||||
virtualRows.length > 0
|
||||
? totalSize - virtualRows[virtualRows.length - 1].end
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"rounded-md border [&>div[data-slot='scroll-area-viewport']>div]:overflow-visible",
|
||||
platform === "macos" ? "h-[340px]" : "h-[280px]",
|
||||
)}
|
||||
>
|
||||
<Table className="overflow-visible">
|
||||
<TableHeader className="overflow-visible">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="overflow-visible">
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width: header.column.columnDef.size
|
||||
? `${header.column.getSize()}px`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody className="overflow-visible">
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const rowIsCrossOs = isCrossOsProfile(row.original);
|
||||
const crossOsTitle = rowIsCrossOs
|
||||
? t("crossOs.viewOnly", {
|
||||
os: getOSDisplayName(
|
||||
row.original.host_os ||
|
||||
row.original.camoufox_config?.os ||
|
||||
row.original.wayfern_config?.os ||
|
||||
"",
|
||||
),
|
||||
})
|
||||
: undefined;
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
title={crossOsTitle}
|
||||
className={cn(
|
||||
"overflow-visible hover:bg-accent/50",
|
||||
rowIsCrossOs && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="overflow-visible"
|
||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||
<div
|
||||
ref={scrollParentRef}
|
||||
className="overflow-auto relative flex-1 min-h-0 scroll-fade"
|
||||
>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader className="overflow-visible sticky top-0 z-10 bg-background [&_tr]:border-0">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow
|
||||
key={headerGroup.id}
|
||||
className="overflow-visible !border-0"
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width: cell.column.columnDef.size
|
||||
? `${cell.column.getSize()}px`
|
||||
width: header.column.columnDef.size
|
||||
? `${header.column.getSize()}px`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody className="overflow-visible">
|
||||
{sortedRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("profiles.table.empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<>
|
||||
{paddingTop > 0 && (
|
||||
<tr style={{ height: `${paddingTop}px` }}>
|
||||
<td colSpan={columns.length} />
|
||||
</tr>
|
||||
)}
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = sortedRows[virtualRow.index];
|
||||
const rowIsCrossOs = isCrossOsProfile(row.original);
|
||||
const crossOsTitle = rowIsCrossOs
|
||||
? t("crossOs.viewOnly", {
|
||||
os: getOSDisplayName(
|
||||
row.original.host_os ||
|
||||
row.original.camoufox_config?.os ||
|
||||
row.original.wayfern_config?.os ||
|
||||
"",
|
||||
),
|
||||
})
|
||||
: undefined;
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
title={crossOsTitle}
|
||||
style={{ height: `${ROW_HEIGHT}px` }}
|
||||
className={cn(
|
||||
"overflow-visible hover:bg-accent/50 !border-0",
|
||||
rowIsCrossOs && "opacity-60",
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("profiles.table.empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="overflow-visible py-0"
|
||||
style={{
|
||||
width: cell.column.columnDef.size
|
||||
? `${cell.column.getSize()}px`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{paddingBottom > 0 && (
|
||||
<tr style={{ height: `${paddingBottom}px` }}>
|
||||
<td colSpan={columns.length} />
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={profileToDelete !== null}
|
||||
onClose={() => {
|
||||
@@ -2704,6 +3043,9 @@ export function ProfilesDataTable({
|
||||
}}
|
||||
onCloneProfile={onCloneProfile}
|
||||
onLaunchWithSync={onLaunchWithSync}
|
||||
onSetPassword={onSetPassword}
|
||||
onChangePassword={onChangePassword}
|
||||
onRemovePassword={onRemovePassword}
|
||||
onDeleteProfile={(profile) => {
|
||||
setProfileForInfoDialog(null);
|
||||
setProfileToDelete(profile);
|
||||
|
||||
+1572
-203
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
extractLockoutSeconds,
|
||||
formatLockoutDuration,
|
||||
translateBackendError,
|
||||
} from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
import { LoadingButton } from "./loading-button";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
export type PasswordDialogMode = "set" | "unlock" | "change" | "remove";
|
||||
|
||||
interface ProfilePasswordDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profile: BrowserProfile | null;
|
||||
mode: PasswordDialogMode;
|
||||
onSuccess?: (profile: BrowserProfile) => void;
|
||||
}
|
||||
|
||||
const MIN_LEN = 8;
|
||||
|
||||
export function ProfilePasswordDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profile,
|
||||
mode,
|
||||
onSuccess,
|
||||
}: ProfilePasswordDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [oldPassword, setOldPassword] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [confirm, setConfirm] = React.useState("");
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [lockoutSecondsRemaining, setLockoutSecondsRemaining] = React.useState<
|
||||
number | null
|
||||
>(null);
|
||||
const firstInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
setOldPassword("");
|
||||
setPassword("");
|
||||
setConfirm("");
|
||||
setIsSubmitting(false);
|
||||
setLockoutSecondsRemaining(null);
|
||||
setTimeout(() => firstInputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Tick down the lockout timer
|
||||
React.useEffect(() => {
|
||||
if (lockoutSecondsRemaining == null) return;
|
||||
if (lockoutSecondsRemaining <= 0) {
|
||||
setLockoutSecondsRemaining(null);
|
||||
return;
|
||||
}
|
||||
const handle = window.setTimeout(() => {
|
||||
setLockoutSecondsRemaining((prev) => (prev == null ? null : prev - 1));
|
||||
}, 1000);
|
||||
return () => {
|
||||
window.clearTimeout(handle);
|
||||
};
|
||||
}, [lockoutSecondsRemaining]);
|
||||
|
||||
if (!profile) return null;
|
||||
|
||||
const needsConfirm = mode === "set" || mode === "change";
|
||||
const needsOldPassword = mode === "change" || mode === "remove";
|
||||
|
||||
const validate = (): string | null => {
|
||||
if (needsOldPassword && !oldPassword) {
|
||||
return t("profilePassword.errors.oldPasswordRequired");
|
||||
}
|
||||
if (mode === "set" || mode === "change") {
|
||||
if (password.length < MIN_LEN) {
|
||||
return t("profilePassword.errors.tooShort", { min: MIN_LEN });
|
||||
}
|
||||
if (password !== confirm) {
|
||||
return t("profilePassword.errors.mismatch");
|
||||
}
|
||||
}
|
||||
if (mode === "unlock" && !password) {
|
||||
return t("profilePassword.errors.passwordRequired");
|
||||
}
|
||||
if (mode === "remove" && !oldPassword) {
|
||||
return t("profilePassword.errors.passwordRequired");
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting || lockoutSecondsRemaining != null) return;
|
||||
const error = validate();
|
||||
if (error) {
|
||||
showErrorToast(error);
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
switch (mode) {
|
||||
case "set":
|
||||
await invoke("set_profile_password", {
|
||||
profileId: profile.id,
|
||||
password,
|
||||
});
|
||||
showSuccessToast(t("profilePassword.toasts.set"));
|
||||
break;
|
||||
case "unlock":
|
||||
await invoke("unlock_profile", {
|
||||
profileId: profile.id,
|
||||
password,
|
||||
});
|
||||
break;
|
||||
case "change":
|
||||
await invoke("change_profile_password", {
|
||||
profileId: profile.id,
|
||||
oldPassword,
|
||||
newPassword: password,
|
||||
});
|
||||
showSuccessToast(t("profilePassword.toasts.changed"));
|
||||
break;
|
||||
case "remove":
|
||||
await invoke("remove_profile_password", {
|
||||
profileId: profile.id,
|
||||
password: oldPassword,
|
||||
});
|
||||
showSuccessToast(t("profilePassword.toasts.removed"));
|
||||
break;
|
||||
}
|
||||
onSuccess?.(profile);
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
const lockoutSeconds = extractLockoutSeconds(err);
|
||||
if (lockoutSeconds != null) {
|
||||
setLockoutSecondsRemaining(lockoutSeconds);
|
||||
} else {
|
||||
showErrorToast(translateBackendError(t, err));
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const titleKey =
|
||||
mode === "set"
|
||||
? "profilePassword.set.title"
|
||||
: mode === "unlock"
|
||||
? "profilePassword.unlock.title"
|
||||
: mode === "change"
|
||||
? "profilePassword.change.title"
|
||||
: "profilePassword.remove.title";
|
||||
|
||||
const descriptionKey =
|
||||
mode === "set"
|
||||
? "profilePassword.set.description"
|
||||
: mode === "unlock"
|
||||
? "profilePassword.unlock.description"
|
||||
: mode === "change"
|
||||
? "profilePassword.change.description"
|
||||
: "profilePassword.remove.description";
|
||||
|
||||
const submitLabelKey =
|
||||
mode === "set"
|
||||
? "profilePassword.set.button"
|
||||
: mode === "unlock"
|
||||
? "profilePassword.unlock.button"
|
||||
: mode === "change"
|
||||
? "profilePassword.change.button"
|
||||
: "profilePassword.remove.button";
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(titleKey)}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(descriptionKey, { name: profile.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
{(mode === "set" || mode === "change") && (
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">
|
||||
<p className="font-medium text-warning-foreground">
|
||||
{t("profilePassword.warnings.forgetWarningTitle")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("profilePassword.warnings.forgetWarningBody")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{lockoutSecondsRemaining != null && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{t("backendErrors.lockedOut", {
|
||||
duration: formatLockoutDuration(t, lockoutSecondsRemaining),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{needsOldPassword && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="profile-pw-old">
|
||||
{mode === "remove"
|
||||
? t("profilePassword.fields.password")
|
||||
: t("profilePassword.fields.currentPassword")}
|
||||
</Label>
|
||||
<Input
|
||||
ref={firstInputRef}
|
||||
id="profile-pw-old"
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleSubmit();
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(mode === "set" || mode === "change" || mode === "unlock") && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="profile-pw-new">
|
||||
{mode === "unlock"
|
||||
? t("profilePassword.fields.password")
|
||||
: t("profilePassword.fields.newPassword")}
|
||||
</Label>
|
||||
<Input
|
||||
ref={!needsOldPassword ? firstInputRef : undefined}
|
||||
id="profile-pw-new"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleSubmit();
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
autoComplete={
|
||||
mode === "unlock" ? "current-password" : "new-password"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{needsConfirm && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="profile-pw-confirm">
|
||||
{t("profilePassword.fields.confirm")}
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-pw-confirm"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleSubmit();
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
onClick={() => void handleSubmit()}
|
||||
isLoading={isSubmitting}
|
||||
disabled={lockoutSecondsRemaining != null}
|
||||
variant={mode === "remove" ? "destructive" : "default"}
|
||||
>
|
||||
{t(submitLabelKey)}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -50,7 +50,15 @@ export function ProfileSelectorDialog({
|
||||
}: ProfileSelectorDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// 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
|
||||
const runningProfiles = externalRunningProfiles ?? hookRunningProfiles;
|
||||
@@ -148,11 +156,7 @@ export function ProfileSelectorDialog({
|
||||
if (runningAvailableProfile) {
|
||||
setSelectedProfile(runningAvailableProfile.name);
|
||||
} else {
|
||||
// Sort profiles by name and select first
|
||||
const sortedProfiles = [...profiles].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
setSelectedProfile(sortedProfiles[0].name);
|
||||
setSelectedProfile(profiles[0].name);
|
||||
}
|
||||
}
|
||||
}, [isOpen, profiles, selectedProfile, runningProfiles]);
|
||||
|
||||
@@ -166,7 +166,7 @@ export function ProfileSyncDialog({
|
||||
}, [profile, hasConfig, onSyncConfigOpen, onClose, t]);
|
||||
|
||||
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);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
@@ -177,7 +177,7 @@ export function ProfileSyncDialog({
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sync.mode.title", "Profile Sync")}</DialogTitle>
|
||||
<DialogTitle>{t("sync.mode.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sync.mode.description", {
|
||||
name: profile.name,
|
||||
@@ -194,9 +194,7 @@ export function ProfileSyncDialog({
|
||||
<div className="grid gap-4 py-4">
|
||||
{!hasConfig && (
|
||||
<div className="p-3 text-sm rounded-md bg-muted">
|
||||
<p className="mb-2">
|
||||
{t("sync.mode.notConfigured", "Sync service not configured.")}
|
||||
</p>
|
||||
<p className="mb-2">{t("sync.mode.notConfigured")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -205,7 +203,7 @@ export function ProfileSyncDialog({
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t("sync.mode.configureService", "Configure Sync Service")}
|
||||
{t("sync.mode.configureService")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -222,13 +220,10 @@ export function ProfileSyncDialog({
|
||||
<RadioGroupItem value="Disabled" id="sync-disabled" />
|
||||
<Label htmlFor="sync-disabled" className="cursor-pointer">
|
||||
<span className="font-medium">
|
||||
{t("sync.mode.disabled", "Disabled")}
|
||||
{t("sync.mode.disabled")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"sync.mode.disabledDescription",
|
||||
"No sync for this profile",
|
||||
)}
|
||||
{t("sync.mode.disabledDescription")}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
@@ -237,13 +232,10 @@ export function ProfileSyncDialog({
|
||||
<RadioGroupItem value="Regular" id="sync-regular" />
|
||||
<Label htmlFor="sync-regular" className="cursor-pointer">
|
||||
<span className="font-medium">
|
||||
{t("sync.mode.regular", "Regular Sync")}
|
||||
{t("sync.mode.regular")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"sync.mode.regularDescription",
|
||||
"Fast sync, unencrypted",
|
||||
)}
|
||||
{t("sync.mode.regularDescription")}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
@@ -263,18 +255,12 @@ export function ProfileSyncDialog({
|
||||
}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{t("sync.mode.encrypted", "E2E Encrypted Sync")}
|
||||
{t("sync.mode.encrypted")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{canUseEncryption
|
||||
? t(
|
||||
"sync.mode.encryptedDescription",
|
||||
"Encrypted before upload. Server never sees plaintext data.",
|
||||
)
|
||||
: t(
|
||||
"settings.encryption.requiresProOrOwner",
|
||||
"Profile encryption is available for Pro users and team owners.",
|
||||
)}
|
||||
? t("sync.mode.encryptedDescription")
|
||||
: t("settings.encryption.requiresProOrOwner")}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
@@ -284,15 +270,12 @@ export function ProfileSyncDialog({
|
||||
!hasE2ePassword &&
|
||||
userChangedMode && (
|
||||
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
|
||||
{t(
|
||||
"sync.mode.noPasswordWarning",
|
||||
"E2E password not set. Please set a password in Settings.",
|
||||
)}
|
||||
{t("sync.mode.noPasswordWarning")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">
|
||||
<Badge variant="outline">
|
||||
{formatLastSync(profile.last_sync)}
|
||||
@@ -319,7 +302,7 @@ export function ProfileSyncDialog({
|
||||
</Button>
|
||||
{hasConfig && isSyncEnabled(profile) && (
|
||||
<LoadingButton onClick={handleSyncNow} isLoading={isSyncing}>
|
||||
{t("sync.mode.syncNow", "Sync Now")}
|
||||
{t("sync.mode.syncNow")}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types";
|
||||
import { ProxyCheckButton } from "./proxy-check-button";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
@@ -100,11 +101,16 @@ function getSyncStatusDot(
|
||||
interface ProxyManagementDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
subPage?: boolean;
|
||||
/** Which tab to display first when the dialog mounts; defaults to "proxies". */
|
||||
initialTab?: "proxies" | "vpns";
|
||||
}
|
||||
|
||||
export function ProxyManagementDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
subPage,
|
||||
initialTab = "proxies",
|
||||
}: ProxyManagementDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// Proxy state
|
||||
@@ -391,27 +397,49 @@ export function ProxyManagementDialog({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("proxies.management.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-[min(95vw,1600px)] max-h-[90vh] flex flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("proxies.management.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<ScrollArea className="overflow-y-auto flex-1">
|
||||
<Tabs defaultValue="proxies">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="proxies" className="flex-1">
|
||||
<ScrollArea className="overflow-y-auto flex-1 scroll-fade">
|
||||
<Tabs key={initialTab} defaultValue={initialTab}>
|
||||
<TabsList
|
||||
className={cn(
|
||||
"w-full",
|
||||
subPage &&
|
||||
"!bg-transparent !p-0 !h-auto !rounded-none justify-start gap-4",
|
||||
)}
|
||||
>
|
||||
<TabsTrigger
|
||||
value="proxies"
|
||||
className={cn(
|
||||
"flex-1",
|
||||
subPage &&
|
||||
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs",
|
||||
)}
|
||||
>
|
||||
{t("proxies.management.tabProxies")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="vpns" className="flex-1">
|
||||
<TabsTrigger
|
||||
value="vpns"
|
||||
className={cn(
|
||||
"flex-1",
|
||||
subPage &&
|
||||
"!flex-none !rounded-none !bg-transparent !shadow-none data-[state=active]:!bg-transparent data-[state=active]:!text-foreground data-[state=active]:!shadow-none text-muted-foreground hover:text-foreground !px-1 !py-1 text-xs",
|
||||
)}
|
||||
>
|
||||
{t("proxies.management.tabVpns")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="proxies">
|
||||
<TabsContent value="proxies" className="mt-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
@@ -460,196 +488,188 @@ export function ProxyManagementDialog({
|
||||
{t("proxies.management.noneCreated")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("common.labels.name")}</TableHead>
|
||||
<TableHead className="w-20">
|
||||
{t("proxies.management.usage")}
|
||||
</TableHead>
|
||||
<TableHead className="w-24">
|
||||
{t("proxies.management.syncCol")}
|
||||
</TableHead>
|
||||
<TableHead className="w-24">
|
||||
{t("common.labels.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{storedProxies.map((proxy) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
proxy,
|
||||
proxySyncStatus[proxy.id],
|
||||
t,
|
||||
proxySyncErrors[proxy.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={proxy.id}>
|
||||
<TableCell className="font-medium">
|
||||
<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>
|
||||
<div className="border rounded-md max-h-[240px] overflow-auto">
|
||||
<Table className="min-w-max">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("common.labels.name")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap w-px">
|
||||
{t("proxies.management.usage")}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap w-px">
|
||||
{t("proxies.management.syncCol")}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap w-px">
|
||||
{t("common.labels.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{storedProxies.map((proxy) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
proxy,
|
||||
proxySyncStatus[proxy.id],
|
||||
t,
|
||||
proxySyncErrors[proxy.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={proxy.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={proxy.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
void handleToggleSync(proxy)
|
||||
}
|
||||
disabled={
|
||||
isTogglingSync[proxy.id] ||
|
||||
proxyInUse[proxy.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
||||
syncDot.animate
|
||||
? "animate-pulse"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{proxyInUse[proxy.id] ? (
|
||||
<p>
|
||||
{t(
|
||||
"proxies.management.syncCannotDisable",
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{proxy.sync_enabled
|
||||
? t(
|
||||
"proxies.management.disableSync",
|
||||
)
|
||||
: t(
|
||||
"proxies.management.enableSync",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p>{syncDot.tooltip}</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>
|
||||
{proxy.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{proxyUsage[proxy.id] ?? 0}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={proxy.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
void handleToggleSync(proxy)
|
||||
}
|
||||
disabled={
|
||||
isTogglingSync[proxy.id] ||
|
||||
proxyInUse[proxy.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{proxyInUse[proxy.id] ? (
|
||||
<p>
|
||||
{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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleEditProxy(proxy);
|
||||
handleDeleteProxy(proxy);
|
||||
}}
|
||||
disabled={
|
||||
(proxyUsage[proxy.id] ?? 0) > 0
|
||||
}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
|
||||
<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>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleDeleteProxy(proxy);
|
||||
}}
|
||||
disabled={
|
||||
(proxyUsage[proxy.id] ?? 0) > 0
|
||||
}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</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>
|
||||
) : (
|
||||
<p>
|
||||
{t(
|
||||
"proxies.management.deleteProxy",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="vpns">
|
||||
<TabsContent value="vpns" className="mt-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
@@ -684,169 +704,167 @@ export function ProxyManagementDialog({
|
||||
{t("vpns.management.noneCreated")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("common.labels.name")}</TableHead>
|
||||
<TableHead className="w-16">
|
||||
{t("common.labels.type")}
|
||||
</TableHead>
|
||||
<TableHead className="w-20">
|
||||
{t("proxies.management.usage")}
|
||||
</TableHead>
|
||||
<TableHead className="w-24">
|
||||
{t("proxies.management.syncCol")}
|
||||
</TableHead>
|
||||
<TableHead className="w-24">
|
||||
{t("common.labels.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vpnConfigs.map((vpn) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
vpn,
|
||||
vpnSyncStatus[vpn.id],
|
||||
t,
|
||||
vpnSyncErrors[vpn.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={vpn.id}>
|
||||
<TableCell className="font-medium">
|
||||
<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>
|
||||
<div className="border rounded-md max-h-[240px] overflow-auto">
|
||||
<Table className="min-w-max">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("common.labels.name")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap w-px">
|
||||
{t("common.labels.type")}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap w-px">
|
||||
{t("proxies.management.usage")}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap w-px">
|
||||
{t("proxies.management.syncCol")}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap w-px">
|
||||
{t("common.labels.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vpnConfigs.map((vpn) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
vpn,
|
||||
vpnSyncStatus[vpn.id],
|
||||
t,
|
||||
vpnSyncErrors[vpn.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={vpn.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
||||
syncDot.animate
|
||||
? "animate-pulse"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{vpnInUse[vpn.id] ? (
|
||||
<p>
|
||||
{t(
|
||||
"vpns.management.syncCannotDisable",
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{vpn.sync_enabled
|
||||
? t(
|
||||
"proxies.management.disableSync",
|
||||
)
|
||||
: t(
|
||||
"proxies.management.enableSync",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<VpnCheckButton
|
||||
vpnId={vpn.id}
|
||||
vpnName={vpn.name}
|
||||
checkingVpnId={checkingVpnId}
|
||||
setCheckingVpnId={setCheckingVpnId}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{vpn.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">WG</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{vpnUsage[vpn.id] ?? 0}
|
||||
</Badge>
|
||||
</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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleEditVpn(vpn);
|
||||
handleDeleteVpn(vpn);
|
||||
}}
|
||||
disabled={
|
||||
(vpnUsage[vpn.id] ?? 0) > 0
|
||||
}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("vpns.management.editVpn")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleDeleteVpn(vpn);
|
||||
}}
|
||||
disabled={
|
||||
(vpnUsage[vpn.id] ?? 0) > 0
|
||||
}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(vpnUsage[vpn.id] ?? 0) > 0 ? (
|
||||
<p>
|
||||
{(vpnUsage[vpn.id] ?? 0) === 1
|
||||
? t(
|
||||
"vpns.management.cannotDelete_one",
|
||||
{ count: vpnUsage[vpn.id] },
|
||||
)
|
||||
: t(
|
||||
"vpns.management.cannotDelete_other",
|
||||
{ count: vpnUsage[vpn.id] },
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{t("vpns.management.deleteVpn")}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(vpnUsage[vpn.id] ?? 0) > 0 ? (
|
||||
<p>
|
||||
{(vpnUsage[vpn.id] ?? 0) === 1
|
||||
? t(
|
||||
"vpns.management.cannotDelete_one",
|
||||
{ count: vpnUsage[vpn.id] },
|
||||
)
|
||||
: t(
|
||||
"vpns.management.cannotDelete_other",
|
||||
{ count: vpnUsage[vpn.id] },
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{t("vpns.management.deleteVpn")}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -854,11 +872,13 @@ export function ProxyManagementDialog({
|
||||
</Tabs>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
{!subPage && (
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal } from "react-icons/go";
|
||||
import { LuCloud, LuPlug, LuPuzzle, LuUser, LuUsers } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "./icons/logo";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
export type AppPage =
|
||||
| "profiles"
|
||||
| "proxies"
|
||||
| "extensions"
|
||||
| "groups"
|
||||
| "vpns"
|
||||
| "settings"
|
||||
| "integrations"
|
||||
| "account"
|
||||
| "import";
|
||||
|
||||
const CLICK_THRESHOLD = 5;
|
||||
const CLICK_WINDOW_MS = 2000;
|
||||
const GRAVITY = 2200;
|
||||
const BOUNCE_DAMPING = 0.6;
|
||||
const INITIAL_HORIZONTAL_SPEED = 350;
|
||||
const SPIN_SPEED = 720;
|
||||
const MIN_BOUNCE_VELOCITY = 60;
|
||||
const LOGO_HIDDEN_KEY = "donut-logo-hidden";
|
||||
|
||||
function useLogoEasterEgg({
|
||||
currentPage,
|
||||
onNavigate,
|
||||
}: {
|
||||
currentPage: AppPage;
|
||||
onNavigate: (page: AppPage) => void;
|
||||
}) {
|
||||
const clickTimestamps = useRef<number[]>([]);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [wobbleKey, setWobbleKey] = useState(0);
|
||||
const [isFalling, setIsFalling] = useState(false);
|
||||
/**
|
||||
* Click count toward the bounce trigger while the user is on the profiles
|
||||
* page. Capped at 4: each click here grows the logo by 25%, so step 4 has
|
||||
* doubled the original size. Click 5 fires `triggerFall` and resets.
|
||||
*/
|
||||
const [growStep, setGrowStep] = useState(0);
|
||||
const resetTimeoutRef = useRef<number | null>(null);
|
||||
const [isHidden, setIsHidden] = useState(() => {
|
||||
try {
|
||||
return sessionStorage.getItem(LOGO_HIDDEN_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const logoRef = useRef<HTMLButtonElement>(null);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
|
||||
const triggerFall = useCallback(() => {
|
||||
const el = logoRef.current;
|
||||
if (!el || isFalling) return;
|
||||
setIsFalling(true);
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const startX = rect.left;
|
||||
const startY = rect.top;
|
||||
const floorY = window.innerHeight;
|
||||
const rightWall = window.innerWidth;
|
||||
|
||||
const clone = el.cloneNode(true) as HTMLElement;
|
||||
clone.style.position = "fixed";
|
||||
clone.style.left = `${startX}px`;
|
||||
clone.style.top = `${startY}px`;
|
||||
clone.style.zIndex = "9999";
|
||||
clone.style.pointerEvents = "none";
|
||||
clone.style.margin = "0";
|
||||
document.body.appendChild(clone);
|
||||
el.style.visibility = "hidden";
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let vy = -500;
|
||||
// Roll right first, bounce off the right wall, then escape the left.
|
||||
let vx = INITIAL_HORIZONTAL_SPEED;
|
||||
let rotation = 0;
|
||||
let lastTime = performance.now();
|
||||
|
||||
const animate = (time: number) => {
|
||||
const dt = Math.min((time - lastTime) / 1000, 0.05);
|
||||
lastTime = time;
|
||||
|
||||
vy += GRAVITY * dt;
|
||||
x += vx * dt;
|
||||
y += vy * dt;
|
||||
rotation += SPIN_SPEED * dt * (vx > 0 ? 1 : -1);
|
||||
|
||||
const currentBottom = startY + y + rect.height;
|
||||
if (currentBottom >= floorY && vy > 0) {
|
||||
y = floorY - startY - rect.height;
|
||||
vy =
|
||||
Math.abs(vy) > MIN_BOUNCE_VELOCITY
|
||||
? -Math.abs(vy) * BOUNCE_DAMPING
|
||||
: -MIN_BOUNCE_VELOCITY * 3;
|
||||
}
|
||||
|
||||
// Right-wall bounce: hit, reverse horizontal velocity (with a tiny
|
||||
// damping), and keep rolling. Left wall has no bounce — the donut
|
||||
// exits the window off the left edge.
|
||||
const currentRight = startX + x + rect.width;
|
||||
if (currentRight >= rightWall && vx > 0) {
|
||||
x = rightWall - startX - rect.width;
|
||||
vx = -Math.abs(vx) * 0.9;
|
||||
}
|
||||
|
||||
clone.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`;
|
||||
|
||||
const offScreenLeft = startX + x + rect.width < -200;
|
||||
const offScreenBottom = startY + y > floorY + 100;
|
||||
const offScreenTop = startY + y + rect.height < -200;
|
||||
|
||||
if (offScreenLeft || offScreenBottom || offScreenTop) {
|
||||
clone.remove();
|
||||
try {
|
||||
sessionStorage.setItem(LOGO_HIDDEN_KEY, "1");
|
||||
} catch {
|
||||
// ignore — sessionStorage unavailable in some Tauri WebViews
|
||||
}
|
||||
setIsHidden(true);
|
||||
setIsFalling(false);
|
||||
return;
|
||||
}
|
||||
animFrameRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
animFrameRef.current = requestAnimationFrame(animate);
|
||||
}, [isFalling]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isFalling || isHidden) return;
|
||||
|
||||
// First behaviour: any click from elsewhere in the app just routes the
|
||||
// user back to the profiles list. Growing the donut requires the user
|
||||
// to already be home — that keeps the easter egg from accidentally
|
||||
// firing during normal navigation.
|
||||
if (currentPage !== "profiles") {
|
||||
onNavigate("profiles");
|
||||
clickTimestamps.current = [];
|
||||
setGrowStep(0);
|
||||
if (resetTimeoutRef.current !== null) {
|
||||
window.clearTimeout(resetTimeoutRef.current);
|
||||
resetTimeoutRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
clickTimestamps.current = clickTimestamps.current.filter(
|
||||
(t) => now - t < CLICK_WINDOW_MS,
|
||||
);
|
||||
clickTimestamps.current.push(now);
|
||||
|
||||
if (clickTimestamps.current.length >= CLICK_THRESHOLD) {
|
||||
clickTimestamps.current = [];
|
||||
setGrowStep(0);
|
||||
if (resetTimeoutRef.current !== null) {
|
||||
window.clearTimeout(resetTimeoutRef.current);
|
||||
resetTimeoutRef.current = null;
|
||||
}
|
||||
triggerFall();
|
||||
} else {
|
||||
setGrowStep(
|
||||
Math.min(clickTimestamps.current.length, CLICK_THRESHOLD - 1),
|
||||
);
|
||||
setWobbleKey((k) => k + 1);
|
||||
if (resetTimeoutRef.current !== null) {
|
||||
window.clearTimeout(resetTimeoutRef.current);
|
||||
}
|
||||
resetTimeoutRef.current = window.setTimeout(() => {
|
||||
clickTimestamps.current = [];
|
||||
setGrowStep(0);
|
||||
resetTimeoutRef.current = null;
|
||||
}, CLICK_WINDOW_MS);
|
||||
}
|
||||
}, [currentPage, isFalling, isHidden, onNavigate, triggerFall]);
|
||||
|
||||
// Leaving the profiles page mid-streak cancels growth so we never end up
|
||||
// with an outsized logo when the user returns later.
|
||||
useEffect(() => {
|
||||
if (currentPage !== "profiles") {
|
||||
clickTimestamps.current = [];
|
||||
setGrowStep(0);
|
||||
if (resetTimeoutRef.current !== null) {
|
||||
window.clearTimeout(resetTimeoutRef.current);
|
||||
resetTimeoutRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resetTimeoutRef.current !== null) {
|
||||
window.clearTimeout(resetTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
logoRef,
|
||||
isPressed,
|
||||
setIsPressed,
|
||||
wobbleKey,
|
||||
isFalling,
|
||||
isHidden,
|
||||
growStep,
|
||||
handleClick,
|
||||
};
|
||||
}
|
||||
|
||||
interface RailNavProps {
|
||||
currentPage: AppPage;
|
||||
onNavigate: (page: AppPage) => void;
|
||||
}
|
||||
|
||||
interface RailItem {
|
||||
page: AppPage;
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
const TOP_ITEMS: RailItem[] = [
|
||||
{ page: "profiles", Icon: LuUser, labelKey: "rail.profiles" },
|
||||
{ page: "proxies", Icon: FiWifi, labelKey: "rail.proxies" },
|
||||
{ page: "extensions", Icon: LuPuzzle, labelKey: "rail.extensions" },
|
||||
{ page: "groups", Icon: LuUsers, labelKey: "rail.groups" },
|
||||
];
|
||||
|
||||
interface MoreMenuItem {
|
||||
page: AppPage;
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
labelKey: string;
|
||||
hintKey: string;
|
||||
}
|
||||
|
||||
const MORE_ITEMS: MoreMenuItem[] = [
|
||||
{
|
||||
page: "import",
|
||||
Icon: FaDownload,
|
||||
labelKey: "rail.more.importProfile",
|
||||
hintKey: "rail.more.importProfileHint",
|
||||
},
|
||||
{
|
||||
page: "integrations",
|
||||
Icon: LuPlug,
|
||||
labelKey: "rail.more.integrations",
|
||||
hintKey: "rail.more.integrationsHint",
|
||||
},
|
||||
{
|
||||
page: "account",
|
||||
Icon: LuCloud,
|
||||
labelKey: "rail.more.account",
|
||||
hintKey: "rail.more.accountHint",
|
||||
},
|
||||
];
|
||||
|
||||
export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
const { t } = useTranslation();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const {
|
||||
logoRef,
|
||||
isPressed,
|
||||
setIsPressed,
|
||||
wobbleKey,
|
||||
isFalling,
|
||||
isHidden,
|
||||
growStep,
|
||||
handleClick,
|
||||
} = useLogoEasterEgg({ currentPage, onNavigate });
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col items-center w-10 py-2 gap-1 bg-background border-r border-border shrink-0 relative">
|
||||
{!isHidden ? (
|
||||
<button
|
||||
ref={logoRef}
|
||||
type="button"
|
||||
aria-label={t("header.donutLogo")}
|
||||
className="grid place-items-center w-7 h-7 rounded-md cursor-pointer select-none text-foreground bg-transparent"
|
||||
onClick={handleClick}
|
||||
onPointerDown={() => {
|
||||
setIsPressed(true);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
setIsPressed(false);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setIsPressed(false);
|
||||
}}
|
||||
>
|
||||
{/* Inner wrapper survives clicks (no `key`) so the scale change
|
||||
animates smoothly across the wiggle layer's remounts. */}
|
||||
<span
|
||||
style={{
|
||||
transform: isPressed
|
||||
? `scale(${(1 + growStep * 0.25) * 0.9})`
|
||||
: `scale(${1 + growStep * 0.25})`,
|
||||
}}
|
||||
className="inline-grid place-items-center transition-transform duration-300 ease-out will-change-transform"
|
||||
>
|
||||
<span
|
||||
key={wobbleKey}
|
||||
className={cn(
|
||||
"inline-grid place-items-center",
|
||||
!isFalling &&
|
||||
!isPressed &&
|
||||
wobbleKey > 0 &&
|
||||
"animate-[wiggle_0.3s_ease-in-out]",
|
||||
)}
|
||||
>
|
||||
<Logo className="w-5 h-5 will-change-transform" />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-7 h-7" />
|
||||
)}
|
||||
|
||||
<div className="w-5 h-px bg-border my-1" />
|
||||
|
||||
{TOP_ITEMS.map(({ page, Icon, labelKey }) => {
|
||||
const active = currentPage === page;
|
||||
return (
|
||||
<Tooltip key={page} delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onNavigate(page);
|
||||
}}
|
||||
aria-label={t(labelKey)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"relative grid place-items-center w-7 h-7 rounded-md transition-colors duration-100",
|
||||
active
|
||||
? "text-foreground bg-accent"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
|
||||
/>
|
||||
)}
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t(labelKey)}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMoreOpen((v) => !v);
|
||||
}}
|
||||
aria-label={t("rail.more.label")}
|
||||
aria-expanded={moreOpen}
|
||||
className={cn(
|
||||
"grid place-items-center w-7 h-7 rounded-md transition-colors duration-100",
|
||||
moreOpen
|
||||
? "text-foreground bg-accent"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<GoKebabHorizontal className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t("rail.more.label")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onNavigate("settings");
|
||||
}}
|
||||
aria-label={t("rail.settings")}
|
||||
aria-current={currentPage === "settings" ? "page" : undefined}
|
||||
className={cn(
|
||||
"relative grid place-items-center w-7 h-7 rounded-md transition-colors duration-100",
|
||||
currentPage === "settings"
|
||||
? "text-foreground bg-accent"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{currentPage === "settings" && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
|
||||
/>
|
||||
)}
|
||||
<GoGear className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t("rail.settings")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{moreOpen && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("rail.more.closeAriaLabel")}
|
||||
className="fixed inset-0 z-30 bg-transparent cursor-default"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute bottom-14 left-11 w-56 bg-card border border-border rounded-lg shadow-2xl p-1 z-40 animate-in fade-in-0 slide-in-from-bottom-1 duration-100">
|
||||
{MORE_ITEMS.map(({ page, Icon, labelKey, hintKey }) => (
|
||||
<button
|
||||
key={page}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
onNavigate(page);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md hover:bg-accent transition-colors duration-100 text-left"
|
||||
>
|
||||
<span className="grid place-items-center w-5 h-5 rounded bg-muted text-muted-foreground shrink-0">
|
||||
<Icon className="w-3 h-3" />
|
||||
</span>
|
||||
<span className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-medium text-foreground truncate">
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{t(hintKey)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import Color from "color";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
THEMES,
|
||||
} from "@/lib/themes";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface AppSettings {
|
||||
@@ -63,6 +65,7 @@ interface AppSettings {
|
||||
api_port: number;
|
||||
api_token?: string;
|
||||
disable_auto_updates?: boolean;
|
||||
keep_decrypted_profiles_in_ram?: boolean;
|
||||
}
|
||||
|
||||
interface CustomThemeState {
|
||||
@@ -82,12 +85,14 @@ interface SettingsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onIntegrationsOpen?: () => void;
|
||||
subPage?: boolean;
|
||||
}
|
||||
|
||||
export function SettingsDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onIntegrationsOpen,
|
||||
subPage,
|
||||
}: SettingsDialogProps) {
|
||||
const [settings, setSettings] = useState<AppSettings>({
|
||||
set_as_default_browser: false,
|
||||
@@ -126,6 +131,7 @@ export function SettingsDialog({
|
||||
const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState("");
|
||||
const [e2eError, setE2eError] = useState("");
|
||||
const [isSavingE2e, setIsSavingE2e] = useState(false);
|
||||
const [isRemovingE2e, setIsRemovingE2e] = useState(false);
|
||||
const [systemInfo, setSystemInfo] = useState<{
|
||||
app_version: string;
|
||||
os: string;
|
||||
@@ -408,7 +414,12 @@ export function SettingsDialog({
|
||||
// Update settings with any generated tokens
|
||||
setSettings(savedSettings);
|
||||
settingsToSave = savedSettings;
|
||||
setTheme(settings.theme === "custom" ? "dark" : settings.theme);
|
||||
// Pass the actual theme value through. Calling setTheme("dark") here
|
||||
// when the user is on "custom" pushes the provider state to "dark",
|
||||
// which triggers its clear-custom-vars effect and wipes the CSS
|
||||
// variables we set just below — that's the bug where saving a custom
|
||||
// theme made it disappear until the app was restarted.
|
||||
setTheme(settings.theme);
|
||||
|
||||
// Apply or clear custom variables only on Save
|
||||
if (settings.theme === "custom") {
|
||||
@@ -539,7 +550,7 @@ export function SettingsDialog({
|
||||
checkDefaultBrowserStatus().catch((err: unknown) => {
|
||||
console.error(err);
|
||||
});
|
||||
}, 500); // Check every 500ms
|
||||
}, 2000);
|
||||
|
||||
// Cleanup interval on component unmount or dialog close
|
||||
return () => {
|
||||
@@ -597,13 +608,20 @@ export function SettingsDialog({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("settings.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!subPage && (
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("settings.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="grid overflow-y-auto flex-1 gap-6 py-4 min-h-0">
|
||||
<div
|
||||
className={cn(
|
||||
"grid overflow-y-auto flex-1 gap-6 min-h-0",
|
||||
subPage ? "py-2" : "py-4",
|
||||
)}
|
||||
>
|
||||
{/* Appearance Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
@@ -811,7 +829,7 @@ export function SettingsDialog({
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose your preferred language for the application interface.
|
||||
{t("settings.language.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -820,10 +838,12 @@ export function SettingsDialog({
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-base font-medium">
|
||||
Default Browser
|
||||
{t("settings.defaultBrowser.title")}
|
||||
</Label>
|
||||
<Badge variant={isDefaultBrowser ? "default" : "secondary"}>
|
||||
{isDefaultBrowser ? "Active" : "Inactive"}
|
||||
{isDefaultBrowser
|
||||
? t("common.status.active")
|
||||
: t("common.status.inactive")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -839,13 +859,12 @@ export function SettingsDialog({
|
||||
className="w-full"
|
||||
>
|
||||
{isDefaultBrowser
|
||||
? "Already Default Browser"
|
||||
: "Set as Default Browser"}
|
||||
? t("settings.defaultBrowser.alreadyDefault")
|
||||
: t("settings.defaultBrowser.setAsDefault")}
|
||||
</LoadingButton>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When set as default, Donut Browser will handle web links and
|
||||
allow you to choose which profile to use.
|
||||
{t("settings.defaultBrowser.description")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -854,12 +873,12 @@ export function SettingsDialog({
|
||||
{isMacOS && (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
System Permissions
|
||||
{t("settings.permissions.title")}
|
||||
</Label>
|
||||
|
||||
{isLoadingPermissions ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading permissions...
|
||||
{t("settings.permissions.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -928,7 +947,7 @@ export function SettingsDialog({
|
||||
className="w-full"
|
||||
onClick={onIntegrationsOpen}
|
||||
>
|
||||
Open Integrations Settings
|
||||
{t("integrations.openSettings")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
@@ -952,39 +971,31 @@ export function SettingsDialog({
|
||||
{/* Sync Encryption Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.encryption.title", "Sync Encryption")}
|
||||
{t("settings.encryption.title")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"settings.encryption.description",
|
||||
"Set a password to enable E2E encrypted sync. If you lose this password, encrypted profiles cannot be recovered.",
|
||||
)}
|
||||
{t("settings.encryption.description")}
|
||||
</p>
|
||||
|
||||
{!canUseEncryption ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"settings.encryption.requiresProOrOwner",
|
||||
"Profile encryption is available for Pro users and team owners.",
|
||||
)}
|
||||
{t("settings.encryption.requiresProOrOwner")}
|
||||
</p>
|
||||
) : hasE2ePassword ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="default">
|
||||
{t("settings.encryption.passwordSet", "Active")}
|
||||
{t("settings.encryption.passwordSet")}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"settings.encryption.passwordSetDescription",
|
||||
"E2E encryption password is set",
|
||||
)}
|
||||
{t("settings.encryption.passwordSetDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isRemovingE2e}
|
||||
onClick={() => {
|
||||
setHasE2ePassword(false);
|
||||
setE2ePassword("");
|
||||
@@ -992,44 +1003,50 @@ export function SettingsDialog({
|
||||
setE2eError("");
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"settings.encryption.changePassword",
|
||||
"Change Password",
|
||||
)}
|
||||
{t("settings.encryption.changePassword")}
|
||||
</Button>
|
||||
<Button
|
||||
<LoadingButton
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
isLoading={isRemovingE2e}
|
||||
onClick={async () => {
|
||||
setIsRemovingE2e(true);
|
||||
try {
|
||||
// Await the rollover so the user sees an error if
|
||||
// re-syncing fails. Previously the rollover was
|
||||
// fire-and-forget (`void invoke(...)`) which left
|
||||
// half-removed state on screen with no feedback —
|
||||
// the source of issue #360 "completely bugged".
|
||||
await invoke("delete_e2e_password");
|
||||
setHasE2ePassword(false);
|
||||
showSuccessToast(
|
||||
t(
|
||||
"settings.encryption.removed",
|
||||
"Encryption password removed",
|
||||
),
|
||||
);
|
||||
try {
|
||||
await invoke(
|
||||
"rollover_encryption_for_all_entities",
|
||||
);
|
||||
} catch (rolloverErr) {
|
||||
console.error(
|
||||
"Rollover after password removal failed:",
|
||||
rolloverErr,
|
||||
);
|
||||
showErrorToast(String(rolloverErr));
|
||||
}
|
||||
showSuccessToast(t("settings.encryption.removed"));
|
||||
} catch (error) {
|
||||
showErrorToast(String(error));
|
||||
} finally {
|
||||
setIsRemovingE2e(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"settings.encryption.removePassword",
|
||||
"Remove Password",
|
||||
)}
|
||||
</Button>
|
||||
{t("settings.encryption.removePassword")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t(
|
||||
"settings.encryption.passwordPlaceholder",
|
||||
"Password (min 8 characters)",
|
||||
)}
|
||||
placeholder={t("settings.encryption.passwordPlaceholder")}
|
||||
value={e2ePassword}
|
||||
onChange={(e) => {
|
||||
setE2ePassword(e.target.value);
|
||||
@@ -1038,10 +1055,7 @@ export function SettingsDialog({
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t(
|
||||
"settings.encryption.confirmPlaceholder",
|
||||
"Confirm password",
|
||||
)}
|
||||
placeholder={t("settings.encryption.confirmPlaceholder")}
|
||||
value={e2ePasswordConfirm}
|
||||
onChange={(e) => {
|
||||
setE2ePasswordConfirm(e.target.value);
|
||||
@@ -1057,21 +1071,11 @@ export function SettingsDialog({
|
||||
isLoading={isSavingE2e}
|
||||
onClick={async () => {
|
||||
if (e2ePassword.length < 8) {
|
||||
setE2eError(
|
||||
t(
|
||||
"settings.encryption.passwordTooShort",
|
||||
"Password must be at least 8 characters",
|
||||
),
|
||||
);
|
||||
setE2eError(t("settings.encryption.passwordTooShort"));
|
||||
return;
|
||||
}
|
||||
if (e2ePassword !== e2ePasswordConfirm) {
|
||||
setE2eError(
|
||||
t(
|
||||
"settings.encryption.passwordMismatch",
|
||||
"Passwords do not match",
|
||||
),
|
||||
);
|
||||
setE2eError(t("settings.encryption.passwordMismatch"));
|
||||
return;
|
||||
}
|
||||
setIsSavingE2e(true);
|
||||
@@ -1082,11 +1086,21 @@ export function SettingsDialog({
|
||||
setHasE2ePassword(true);
|
||||
setE2ePassword("");
|
||||
setE2ePasswordConfirm("");
|
||||
try {
|
||||
// Await rollover so any failure surfaces to the
|
||||
// user instead of being lost via fire-and-forget.
|
||||
// Without this, "change password" leaves entities
|
||||
// half-re-encrypted with no visible error.
|
||||
await invoke("rollover_encryption_for_all_entities");
|
||||
} catch (rolloverErr) {
|
||||
console.error(
|
||||
"Rollover after password set failed:",
|
||||
rolloverErr,
|
||||
);
|
||||
showErrorToast(String(rolloverErr));
|
||||
}
|
||||
showSuccessToast(
|
||||
t(
|
||||
"settings.encryption.passwordSaved",
|
||||
"Encryption password set",
|
||||
),
|
||||
t("settings.encryption.passwordSaved"),
|
||||
);
|
||||
} catch (error) {
|
||||
showErrorToast(String(error));
|
||||
@@ -1095,7 +1109,7 @@ export function SettingsDialog({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("settings.encryption.setPassword", "Set Password")}
|
||||
{t("settings.encryption.setPassword")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -1108,7 +1122,23 @@ export function SettingsDialog({
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/40">
|
||||
{trialStatus?.type === "Active" ? (
|
||||
{cloudUser != null && cloudUser.plan !== "free" ? (
|
||||
// Paid Donut plan supersedes the local commercial trial —
|
||||
// the trial only exists to gate commercial use until the
|
||||
// user subscribes. Showing "Trial expired" to a paying
|
||||
// customer reads like a billing error, so swap in a
|
||||
// subscription-active badge instead.
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-success">
|
||||
{t("settings.commercial.subscriptionActive", {
|
||||
plan: cloudUser.plan,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.commercial.subscriptionActiveDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : trialStatus?.type === "Active" ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.commercial.trialActive", {
|
||||
@@ -1162,6 +1192,30 @@ export function SettingsDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start space-x-3 p-3 rounded-lg border">
|
||||
<Checkbox
|
||||
id="keep-decrypted-profiles-in-ram"
|
||||
checked={settings.keep_decrypted_profiles_in_ram ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
updateSetting(
|
||||
"keep_decrypted_profiles_in_ram",
|
||||
checked as boolean,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="keep-decrypted-profiles-in-ram"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("settings.keepDecryptedProfilesInRam")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.keepDecryptedProfilesInRamDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoadingButton
|
||||
isLoading={isClearingCache}
|
||||
onClick={() => {
|
||||
@@ -1172,13 +1226,45 @@ export function SettingsDialog({
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Clear All Version Cache
|
||||
{t("settings.advanced.clearCache")}
|
||||
</LoadingButton>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Clear all cached browser version data and refresh all browser
|
||||
versions from their sources. This will force a fresh download of
|
||||
version information for all browsers.
|
||||
{t("settings.advanced.clearCacheDescription")}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const content = await invoke<string>("read_log_files");
|
||||
await writeClipboardText(content);
|
||||
showSuccessToast(t("settings.advanced.copyLogsSuccess"));
|
||||
} catch (err) {
|
||||
showErrorToast(String(err));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("settings.advanced.copyLogs")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await invoke("open_log_directory");
|
||||
} catch (err) {
|
||||
showErrorToast(String(err));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("settings.advanced.openLogDir")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.advanced.copyLogsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1192,22 +1278,39 @@ export function SettingsDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isSaving}
|
||||
onClick={() => {
|
||||
handleSave().catch((err: unknown) => {
|
||||
console.error(err);
|
||||
});
|
||||
}}
|
||||
disabled={isLoading || !hasChanges}
|
||||
>
|
||||
Save Settings
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
{subPage ? (
|
||||
<div className="shrink-0 flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
isLoading={isSaving}
|
||||
onClick={() => {
|
||||
handleSave().catch((err: unknown) => {
|
||||
console.error(err);
|
||||
});
|
||||
}}
|
||||
disabled={isLoading || !hasChanges}
|
||||
>
|
||||
{t("common.buttons.saveSettings")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
) : (
|
||||
<DialogFooter className="shrink-0">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isSaving}
|
||||
onClick={() => {
|
||||
handleSave().catch((err: unknown) => {
|
||||
console.error(err);
|
||||
});
|
||||
}}
|
||||
disabled={isLoading || !hasChanges}
|
||||
>
|
||||
{t("common.buttons.saveSettings")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DnsBlocklistDialog
|
||||
|
||||
@@ -32,6 +32,14 @@ const DEVICE_LINK_URL = "https://donutbrowser.com/auth/link";
|
||||
interface SyncConfigDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: (loginOccurred?: boolean) => void;
|
||||
/**
|
||||
* Called after the user clicks "Login" so the parent can open the
|
||||
* device-code verify dialog as a separate step. Implementations should
|
||||
* close this dialog and open the verify one — that keeps the verify
|
||||
* step visually independent and avoids stacking on top of other
|
||||
* dialogs (e.g. the profile selector triggered by deep links).
|
||||
*/
|
||||
onLoginStarted?: () => void;
|
||||
}
|
||||
|
||||
interface ProxyUsage {
|
||||
@@ -42,7 +50,11 @@ interface ProxyUsage {
|
||||
extra_limit_mb: number;
|
||||
}
|
||||
|
||||
export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
export function SyncConfigDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onLoginStarted,
|
||||
}: SyncConfigDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Self-hosted state
|
||||
@@ -58,11 +70,8 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
user,
|
||||
isLoggedIn,
|
||||
isLoading: isCloudLoading,
|
||||
exchangeDeviceCode,
|
||||
logout,
|
||||
} = useCloudAuth();
|
||||
const [linkCode, setLinkCode] = useState("");
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>("cloud");
|
||||
const [, setLiveProxyUsage] = useState<ProxyUsage | null>(null);
|
||||
@@ -103,7 +112,6 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
if (isOpen) {
|
||||
setConnectionStatus("unknown");
|
||||
void loadSettings();
|
||||
setLinkCode("");
|
||||
void invoke<ProxyUsage | null>("cloud_get_proxy_usage")
|
||||
.then(setLiveProxyUsage)
|
||||
.catch(() => {
|
||||
@@ -199,32 +207,15 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
const handleOpenLogin = useCallback(async () => {
|
||||
try {
|
||||
await invoke("handle_url_open", { url: DEVICE_LINK_URL });
|
||||
// Hand off the verify step to its own dialog so the user has a
|
||||
// focused place to paste the code, and so it doesn't visually
|
||||
// stack with this dialog or any other modal currently on screen.
|
||||
onLoginStarted?.();
|
||||
} catch (error) {
|
||||
console.error("Failed to open login link:", error);
|
||||
showErrorToast(String(error));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleVerifyCode = useCallback(async () => {
|
||||
const trimmed = linkCode.trim();
|
||||
if (!trimmed) return;
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
await exchangeDeviceCode(trimmed);
|
||||
showSuccessToast(t("sync.cloud.loginSuccess"));
|
||||
try {
|
||||
await invoke("restart_sync_service");
|
||||
} catch (e) {
|
||||
console.error("Failed to restart sync service:", e);
|
||||
}
|
||||
onClose(true);
|
||||
} catch (error) {
|
||||
console.error("Device-code exchange failed:", error);
|
||||
showErrorToast(String(error));
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
}, [linkCode, exchangeDeviceCode, t, onClose]);
|
||||
}, [onLoginStarted]);
|
||||
|
||||
const handleCloudLogout = useCallback(async () => {
|
||||
try {
|
||||
@@ -375,37 +366,6 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
>
|
||||
{t("sync.cloud.openLogin")}
|
||||
</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>
|
||||
)}
|
||||
</TabsContent>
|
||||
@@ -452,7 +412,11 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
setShowToken(!showToken);
|
||||
}}
|
||||
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 ? (
|
||||
<LuEyeOff className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuCopy } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { showSuccessToast } from "@/lib/toast-utils";
|
||||
@@ -26,6 +27,7 @@ export function CopyToClipboard({
|
||||
className,
|
||||
successMessage = "Copied to clipboard",
|
||||
}: CopyToClipboardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
@@ -47,16 +49,18 @@ export function CopyToClipboard({
|
||||
size={size}
|
||||
className={`relative ${className ?? ""}`}
|
||||
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
|
||||
className={`h-4 w-4 transition-all duration-300 ${
|
||||
className={`h-4 w-4 transition-all duration-150 ${
|
||||
copied ? "scale-0" : "scale-100"
|
||||
}`}
|
||||
/>
|
||||
<LuCheck
|
||||
className={`absolute inset-0 m-auto h-4 w-4 text-foreground transition-all duration-300 ${
|
||||
className={`absolute inset-0 m-auto h-4 w-4 text-foreground transition-all duration-150 ${
|
||||
copied ? "scale-100" : "scale-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -14,14 +14,21 @@ import { WindowDragArea } from "../window-drag-area";
|
||||
type DialogContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: DialogProps["onOpenChange"];
|
||||
subPage: boolean;
|
||||
container: HTMLElement | null | undefined;
|
||||
};
|
||||
|
||||
const [DialogProvider, useDialog] =
|
||||
getStrictContext<DialogContextType>("DialogContext");
|
||||
|
||||
type DialogProps = React.ComponentProps<typeof DialogPrimitive.Root>;
|
||||
type DialogProps = React.ComponentProps<typeof DialogPrimitive.Root> & {
|
||||
/** Render in a portal container as an in-flow sub-page instead of a centered modal. */
|
||||
subPage?: boolean;
|
||||
/** Portal container target. Required when subPage=true; ignored otherwise. */
|
||||
container?: HTMLElement | null;
|
||||
};
|
||||
|
||||
function Dialog(props: DialogProps) {
|
||||
function Dialog({ subPage, container, children, ...props }: DialogProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
@@ -29,12 +36,27 @@ function Dialog(props: DialogProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<DialogProvider value={{ isOpen, setIsOpen }}>
|
||||
<DialogProvider
|
||||
value={{
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
subPage: !!subPage,
|
||||
container: container ?? undefined,
|
||||
}}
|
||||
>
|
||||
{/* In sub-page mode the Dialog isn't a modal — it's an in-flow page.
|
||||
Forcing `modal={false}` prevents Radix from locking pointer-events
|
||||
and aria-hiding everything outside the dialog. Children are passed
|
||||
explicitly (not via spread) so React doesn't have to guess where
|
||||
the JSX subtree should mount. */}
|
||||
<DialogPrimitive.Root
|
||||
data-slot="dialog"
|
||||
{...props}
|
||||
modal={subPage ? false : props.modal}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</DialogPrimitive.Root>
|
||||
</DialogProvider>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +73,7 @@ type DialogPortalProps = Omit<
|
||||
>;
|
||||
|
||||
function DialogPortal(props: DialogPortalProps) {
|
||||
const { isOpen } = useDialog();
|
||||
const { isOpen, container } = useDialog();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -59,6 +81,7 @@ function DialogPortal(props: DialogPortalProps) {
|
||||
<DialogPrimitive.Portal
|
||||
data-slot="dialog-portal"
|
||||
forceMount
|
||||
container={container ?? props.container}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
@@ -102,8 +125,54 @@ type DialogContentProps = Omit<
|
||||
> &
|
||||
HTMLMotionProps<"div"> & {
|
||||
from?: DialogFlipDirection;
|
||||
/**
|
||||
* Suppress the built-in top-right close X. Use when the dialog renders
|
||||
* its own header bar with a custom close control to avoid two X buttons
|
||||
* stacking near the corner.
|
||||
*/
|
||||
hideClose?: boolean;
|
||||
};
|
||||
|
||||
function SubPageContent({
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { isOpen } = useDialog();
|
||||
if (!isOpen) return null;
|
||||
// Inline styles deliberately override any className the caller passed
|
||||
// for the modal mode (max-w-*, max-h-*, my-*). tailwind-merge inside the
|
||||
// shared dialog wrappers turned out to be unreliable when both classnames
|
||||
// and !important variants competed — inline styles guarantee the layout.
|
||||
return (
|
||||
<motion.div
|
||||
data-slot="sub-page"
|
||||
data-sub-page="true"
|
||||
initial={false}
|
||||
animate={{ opacity: 1 }}
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: "1 1 0%",
|
||||
minHeight: 0,
|
||||
width: "100%",
|
||||
maxWidth: "none",
|
||||
height: "100%",
|
||||
maxHeight: "none",
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
gap: 12,
|
||||
overflow: "auto",
|
||||
background: "var(--background)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
@@ -113,14 +182,25 @@ function DialogContent({
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
onInteractOutside,
|
||||
transition = { type: "spring", stiffness: 150, damping: 25 },
|
||||
transition,
|
||||
hideClose,
|
||||
...props
|
||||
}: DialogContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const { subPage } = useDialog();
|
||||
const initialRotation =
|
||||
from === "bottom" || from === "left" ? "20deg" : "-20deg";
|
||||
const isVertical = from === "top" || from === "bottom";
|
||||
const rotateAxis = isVertical ? "rotateX" : "rotateY";
|
||||
const finalTransition = transition ?? {
|
||||
type: "spring",
|
||||
stiffness: 220,
|
||||
damping: 26,
|
||||
};
|
||||
|
||||
if (subPage) {
|
||||
return <SubPageContent>{children}</SubPageContent>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
@@ -158,18 +238,20 @@ function DialogContent({
|
||||
filter: "blur(4px)",
|
||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
||||
}}
|
||||
transition={transition}
|
||||
transition={finalTransition}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<RxCross2 />
|
||||
<span className="sr-only">{t("common.buttons.close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
{!hideClose && (
|
||||
<DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<RxCross2 />
|
||||
<span className="sr-only">{t("common.buttons.close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</motion.div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
|
||||
@@ -43,23 +43,20 @@ export function WindowDragArea() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// macOS: transparent drag area overlay
|
||||
// macOS: nothing to render here. The transparent native titlebar (set via
|
||||
// `set_transparent_titlebar(true)` in src-tauri/src/lib.rs) lets the OS
|
||||
// handle dragging directly, and the sys-bar inside `home-header.tsx`
|
||||
// declares its own `data-tauri-drag-region` overlay for the WebView area.
|
||||
// The previous full-width fixed z-[999999] button was stealing every
|
||||
// click in the top 40px of the window.
|
||||
if (platform === "macos") {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="fixed top-0 right-0 left-0 h-10 bg-transparent border-0 z-[999999] select-none"
|
||||
data-window-drag-area="true"
|
||||
onPointerDown={handlePointerDown}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Windows: custom title bar with drag area + minimize/close buttons
|
||||
// Windows: minimize/close controls anchored at the top-right corner of
|
||||
// the sys-bar. The HomeHeader's own drag-region overlay handles window
|
||||
// dragging via Tauri 2, so we don't need a separate draggable spacer
|
||||
// covering the whole width.
|
||||
const handleMinimize = async () => {
|
||||
try {
|
||||
await getCurrentWindow().minimize();
|
||||
@@ -75,64 +72,54 @@ export function WindowDragArea() {
|
||||
console.error("Failed to close window:", error);
|
||||
}
|
||||
};
|
||||
void handlePointerDown; // kept for backwards-compat; not used on Windows now
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-0 right-0 left-0 h-10 z-[999999] flex items-center select-none"
|
||||
data-window-drag-area="true"
|
||||
className="fixed top-0 right-0 z-50 flex items-center h-11 select-none"
|
||||
aria-hidden="false"
|
||||
>
|
||||
{/* Draggable area */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 h-full bg-transparent border-0 cursor-default"
|
||||
onPointerDown={handlePointerDown}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick={() => {
|
||||
void handleMinimize();
|
||||
}}
|
||||
/>
|
||||
{/* Window control buttons */}
|
||||
<div className="flex items-center h-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleMinimize();
|
||||
}}
|
||||
className="flex items-center justify-center w-12 h-full hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
className="flex items-center justify-center w-11 h-full hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
aria-label={t("common.window.minimize")}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="1"
|
||||
viewBox="0 0 10 1"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label={t("common.window.minimize")}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="1"
|
||||
viewBox="0 0 10 1"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label={t("common.window.minimize")}
|
||||
>
|
||||
<rect width="10" height="1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleClose();
|
||||
}}
|
||||
className="flex items-center justify-center w-12 h-full hover:bg-destructive/90 transition-colors text-muted-foreground hover:text-destructive-foreground"
|
||||
<rect width="10" height="1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleClose();
|
||||
}}
|
||||
className="flex items-center justify-center w-11 h-full hover:bg-destructive/90 transition-colors text-muted-foreground hover:text-destructive-foreground"
|
||||
aria-label={t("common.buttons.close")}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
role="img"
|
||||
aria-label={t("common.buttons.close")}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
role="img"
|
||||
aria-label={t("common.buttons.close")}
|
||||
>
|
||||
<line x1="1" y1="1" x2="9" y2="9" />
|
||||
<line x1="9" y1="1" x2="1" y2="9" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<line x1="1" y1="1" x2="9" y2="9" />
|
||||
<line x1="9" y1="1" x2="1" y2="9" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react";
|
||||
|
||||
interface CommonControlledStateProps<T> {
|
||||
value?: T;
|
||||
defaultValue?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns either the caller-controlled `value` (read straight from props) or
|
||||
* an internal state when uncontrolled. The previous implementation kept the
|
||||
* controlled prop in a useEffect-synced state, which lagged one render
|
||||
* behind — when two sibling consumers flipped their `value` props in the
|
||||
* same React batch, both saw stale state for one render and the wrong tree
|
||||
* mounted briefly. Returning the prop directly when controlled makes the
|
||||
* component synchronous in the controlled case, matching React's controlled
|
||||
* input pattern.
|
||||
*/
|
||||
export function useControlledState<T, Rest extends unknown[] = []>(
|
||||
props: CommonControlledStateProps<T> & {
|
||||
onChange?: (value: T, ...args: Rest) => void;
|
||||
},
|
||||
): readonly [T, (next: T, ...args: Rest) => void] {
|
||||
const { value, defaultValue, onChange } = props;
|
||||
|
||||
const [internalState, setInternalState] = React.useState<T>(
|
||||
value ?? (defaultValue as T),
|
||||
);
|
||||
|
||||
const isControlled = value !== undefined;
|
||||
const currentState = isControlled ? value : internalState;
|
||||
|
||||
const setState = React.useCallback(
|
||||
(next: T, ...args: Rest) => {
|
||||
// Always notify caller via onChange so a controlled consumer can
|
||||
// update its own state. Internal state is only relevant in the
|
||||
// uncontrolled case but we keep it in sync so the hook reads the
|
||||
// right value if the consumer later removes its controlled prop.
|
||||
if (!isControlled) {
|
||||
setInternalState(next);
|
||||
}
|
||||
onChange?.(next, ...args);
|
||||
},
|
||||
[isControlled, onChange],
|
||||
);
|
||||
|
||||
return [currentState, setState] as const;
|
||||
}
|
||||
@@ -159,7 +159,7 @@ export function usePermissions(): UsePermissionsReturn {
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
void checkPermissions();
|
||||
}, 500);
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
|
||||
@@ -122,61 +122,25 @@ export function useProfileEvents(): UseProfileEventsReturn {
|
||||
};
|
||||
}, [loadProfiles, loadGroups]);
|
||||
|
||||
// Sync profile running states periodically to ensure consistency
|
||||
// Hydrate the initial runningProfiles set from the loaded list — every
|
||||
// profile that has a stored process_id is a candidate. The Rust status
|
||||
// checker emits profile-running-changed for any transitions; we then
|
||||
// mutate the Set incrementally instead of fan-out-polling all N profiles
|
||||
// every 30s (which was O(N) sysinfo scans and saturated the runtime for
|
||||
// users with hundreds of profiles).
|
||||
useEffect(() => {
|
||||
const syncRunningStates = async () => {
|
||||
if (profiles.length === 0) return;
|
||||
|
||||
try {
|
||||
const statusChecks = profiles.map(async (profile) => {
|
||||
try {
|
||||
const isRunning = await invoke<boolean>("check_browser_status", {
|
||||
profile,
|
||||
});
|
||||
return { id: profile.id, isRunning };
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to check status for profile ${profile.name}:`,
|
||||
error,
|
||||
);
|
||||
return { id: profile.id, isRunning: false };
|
||||
}
|
||||
});
|
||||
|
||||
const statuses = await Promise.all(statusChecks);
|
||||
|
||||
setRunningProfiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
let hasChanges = false;
|
||||
|
||||
statuses.forEach(({ id, isRunning }) => {
|
||||
if (isRunning && !prev.has(id)) {
|
||||
next.add(id);
|
||||
hasChanges = true;
|
||||
} else if (!isRunning && prev.has(id)) {
|
||||
next.delete(id);
|
||||
hasChanges = true;
|
||||
}
|
||||
});
|
||||
|
||||
return hasChanges ? next : prev;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to sync profile running states:", error);
|
||||
setRunningProfiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const p of profiles) {
|
||||
if (p.process_id != null) next.add(p.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial sync
|
||||
void syncRunningStates();
|
||||
|
||||
// Sync every 30 seconds to catch any missed events
|
||||
const interval = setInterval(() => {
|
||||
void syncRunningStates();
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
// Drop ids for profiles that no longer exist
|
||||
const valid = new Set(profiles.map((p) => p.id));
|
||||
for (const id of next) {
|
||||
if (!valid.has(id)) next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [profiles]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { type RefObject, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Track scroll position on a vertical scroll container and write the result
|
||||
* to `data-fade-top` / `data-fade-bottom` attributes on the element. The
|
||||
* `.scroll-fade` CSS utility in `globals.css` reads these attributes and
|
||||
* shows fade gradients only in directions that are actually scrollable.
|
||||
*
|
||||
* A ResizeObserver watches the container AND its direct children so internal
|
||||
* content height changes (e.g. virtualizer padding rows growing/shrinking
|
||||
* as the user scrolls) recompute the fade state automatically.
|
||||
*/
|
||||
export function useScrollFade<T extends HTMLElement>(
|
||||
ref: RefObject<T | null>,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const update = () => {
|
||||
const fadeTop = el.scrollTop > 1;
|
||||
const fadeBottom = el.scrollHeight - el.clientHeight - el.scrollTop > 1;
|
||||
el.setAttribute("data-fade-top", fadeTop ? "true" : "false");
|
||||
el.setAttribute("data-fade-bottom", fadeBottom ? "true" : "false");
|
||||
};
|
||||
|
||||
update();
|
||||
el.addEventListener("scroll", update, { passive: true });
|
||||
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
for (const child of Array.from(el.children)) {
|
||||
ro.observe(child);
|
||||
}
|
||||
|
||||
// MutationObserver picks up DOM additions (virtualizer mounts new rows)
|
||||
// and re-attaches the ResizeObserver to the new children. Without this,
|
||||
// newly inserted rows wouldn't trigger a fade recompute.
|
||||
const mo = new MutationObserver(() => {
|
||||
ro.disconnect();
|
||||
ro.observe(el);
|
||||
for (const child of Array.from(el.children)) {
|
||||
ro.observe(child);
|
||||
}
|
||||
update();
|
||||
});
|
||||
mo.observe(el, { childList: true, subtree: true });
|
||||
|
||||
return () => {
|
||||
el.removeEventListener("scroll", update);
|
||||
ro.disconnect();
|
||||
mo.disconnect();
|
||||
};
|
||||
}, [ref]);
|
||||
}
|
||||
+302
-29
@@ -30,7 +30,9 @@
|
||||
"saveSettings": "Save Settings",
|
||||
"moreInfo": "More info",
|
||||
"downloading": "Downloading...",
|
||||
"minimize": "Minimize"
|
||||
"minimize": "Minimize",
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved"
|
||||
},
|
||||
"status": {
|
||||
"active": "Active",
|
||||
@@ -60,7 +62,8 @@
|
||||
"optional": "Optional",
|
||||
"required": "Required",
|
||||
"unknownProfile": "Unknown",
|
||||
"mode": "Mode"
|
||||
"mode": "Mode",
|
||||
"never": "Never"
|
||||
},
|
||||
"time": {
|
||||
"days": "days",
|
||||
@@ -72,7 +75,11 @@
|
||||
"aria": {
|
||||
"selectAll": "Select all",
|
||||
"selectRow": "Select row",
|
||||
"selectProfile": "Select profile"
|
||||
"selectProfile": "Select profile",
|
||||
"copy": "Copy to clipboard",
|
||||
"copied": "Copied",
|
||||
"showToken": "Show token",
|
||||
"hideToken": "Hide token"
|
||||
},
|
||||
"keys": {
|
||||
"escape": "Escape"
|
||||
@@ -87,7 +94,11 @@
|
||||
"title": "Command Palette",
|
||||
"description": "Search for a command to run..."
|
||||
},
|
||||
"noResults": "No results found."
|
||||
"noResults": "No results found.",
|
||||
"srOnly": {
|
||||
"copy": "Copy",
|
||||
"copied": "Copied"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
@@ -152,18 +163,26 @@
|
||||
"commercial": {
|
||||
"title": "Commercial License",
|
||||
"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",
|
||||
"trialExpiredDescription": "Personal use remains free. Commercial use requires a license."
|
||||
"trialExpiredDescription": "Personal use remains free. Commercial use requires a license.",
|
||||
"subscriptionActive": "Subscribed — {{plan}} plan",
|
||||
"subscriptionActiveDescription": "Your Donut Browser subscription is active. Commercial use is licensed for the duration of your plan."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"clearCache": "Clear All Version Cache",
|
||||
"clearCacheDescription": "Clear all cached browser version data and refresh all browser versions from their sources. This will force a fresh download of version information for all browsers.",
|
||||
"clearCacheFailed": "Failed to clear cache"
|
||||
"clearCacheFailed": "Failed to clear cache",
|
||||
"copyLogs": "Copy logs",
|
||||
"openLogDir": "Open log folder",
|
||||
"copyLogsSuccess": "Logs copied to clipboard",
|
||||
"copyLogsDescription": "Bundles the most recent log files (up to 5 MB) into your clipboard for sharing in bug reports."
|
||||
},
|
||||
"disableAutoUpdates": "Disable App Auto Updates",
|
||||
"disableAutoUpdatesDescription": "Prevent the app from automatically checking and installing Donut Browser updates. Browser updates are not affected."
|
||||
"disableAutoUpdatesDescription": "Prevent the app from automatically checking and installing Donut Browser updates. Browser updates are not affected.",
|
||||
"keepDecryptedProfilesInRam": "Keep Decrypted Profiles In RAM",
|
||||
"keepDecryptedProfilesInRamDescription": "Preserve the decrypted in-RAM copy of password-protected profiles between launches for faster startup. The on-disk copy stays encrypted regardless."
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Search profiles...",
|
||||
@@ -178,7 +197,11 @@
|
||||
"integrations": "Integrations",
|
||||
"importProfile": "Import Profile",
|
||||
"extensions": "Extensions"
|
||||
}
|
||||
},
|
||||
"newProfile": "New",
|
||||
"donutLogo": "Donut Browser logo",
|
||||
"scrollGroupsLeft": "Scroll groups left",
|
||||
"scrollGroupsRight": "Scroll groups right"
|
||||
},
|
||||
"profiles": {
|
||||
"title": "Profiles",
|
||||
@@ -196,7 +219,14 @@
|
||||
"group": "Group",
|
||||
"proxy": "Proxy / VPN",
|
||||
"lastLaunch": "Last Launch",
|
||||
"empty": "No profiles found."
|
||||
"empty": "No profiles found.",
|
||||
"notSelected": "Not Selected",
|
||||
"ext": "EXT",
|
||||
"dns": "DNS",
|
||||
"extDefault": "Default",
|
||||
"dnsLevel": "DNS blocklist: {{level}}",
|
||||
"extSearch": "Search groups…",
|
||||
"extEmpty": "No extension groups"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Launch",
|
||||
@@ -211,7 +241,10 @@
|
||||
"assignToGroup": "Assign to Group",
|
||||
"changeFingerprint": "Change Fingerprint",
|
||||
"copyCookiesToProfile": "Copy Cookies to Profile",
|
||||
"launchHook": "Launch Hook URL"
|
||||
"launchHook": "Launch Hook URL",
|
||||
"setPassword": "Set Password",
|
||||
"changePassword": "Change Password",
|
||||
"removePassword": "Remove Password"
|
||||
},
|
||||
"synchronizer": {
|
||||
"launchWithSync": "Launch with Synchronizer",
|
||||
@@ -230,9 +263,8 @@
|
||||
"flakyTooltip": "This profile has a different screen resolution than the leader. Page layouts may differ, causing clicks and interactions to hit the wrong elements."
|
||||
},
|
||||
"ephemeral": "Ephemeral",
|
||||
"ephemeralDescription": "The browser is forced to write profile data into memory instead of disk. Data is deleted when the browser is closed.",
|
||||
"ephemeralDescription": "The browser is forced to write profile data into memory instead of disk. Note that your operating system can swap parts of memory to disk under load, so traces of the session may still be recoverable.",
|
||||
"ephemeralBadge": "Ephemeral",
|
||||
"ephemeralAlpha": "Alpha",
|
||||
"bulkDelete": {
|
||||
"title": "Delete Selected Profiles",
|
||||
"description": "This action cannot be undone. This will permanently delete {{count}} profile(s) and all associated data.",
|
||||
@@ -255,6 +287,10 @@
|
||||
"assignProxy": "Assign Proxy",
|
||||
"assignExtensionGroup": "Assign Extension Group",
|
||||
"copyCookies": "Copy Cookies"
|
||||
},
|
||||
"passwordProtectedBadge": "Password Protected",
|
||||
"launchHook": {
|
||||
"placeholder": "https://example.com/track-launch"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -302,7 +338,11 @@
|
||||
"firefoxLabel": "Firefox",
|
||||
"firefoxSubtitle": "Powered by Camoufox",
|
||||
"camoufoxWarning": "Firefox (Camoufox) is maintained by a third-party organization. For production use, please use Chromium.",
|
||||
"platformUnavailable": "{{browser}} is not available on your platform yet."
|
||||
"platformUnavailable": "{{browser}} is not available on your platform yet.",
|
||||
"passwordProtect": {
|
||||
"label": "Password protect this profile",
|
||||
"description": "Encrypts the on-disk profile data. Required to launch."
|
||||
}
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Delete Profile",
|
||||
@@ -488,7 +528,9 @@
|
||||
"deleteGroupAndProfiles": "Delete Group & Profiles",
|
||||
"loadProfilesFailed": "Failed to load profiles",
|
||||
"unknownGroup": "Unknown Group",
|
||||
"profileGroupsAriaLabel": "Profile groups"
|
||||
"profileGroupsAriaLabel": "Profile groups",
|
||||
"loading": "Loading groups...",
|
||||
"all": "All"
|
||||
},
|
||||
"sync": {
|
||||
"mode": {
|
||||
@@ -540,6 +582,7 @@
|
||||
"openLogin": "Login",
|
||||
"linkCodeLabel": "Login code",
|
||||
"linkCodePlaceholder": "Paste the code from the website",
|
||||
"signInTitle": "Sign in",
|
||||
"verifyAndLogin": "Verify & Log In",
|
||||
"loggingIn": "Logging in...",
|
||||
"connected": "Connected",
|
||||
@@ -631,7 +674,8 @@
|
||||
"mcpAcceptTermsFirst": "(Accept Wayfern terms in Settings first)",
|
||||
"mcpStarted": "MCP server started on port {{port}}",
|
||||
"mcpStopped": "MCP server stopped",
|
||||
"mcpToggleFailed": "Failed to toggle MCP server"
|
||||
"mcpToggleFailed": "Failed to toggle MCP server",
|
||||
"openSettings": "Open Integrations Settings"
|
||||
},
|
||||
"import": {
|
||||
"title": "Import Profile",
|
||||
@@ -711,6 +755,10 @@
|
||||
"webrtc": "Block WebRTC",
|
||||
"webgl": "Block WebGL"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
"browserBehavior": "Browser Behavior",
|
||||
"allowAddonsOpenTabs": "Allow browser addons to open new tabs automatically"
|
||||
}
|
||||
},
|
||||
"cookies": {
|
||||
@@ -875,7 +923,9 @@
|
||||
"loadProxiesFailed": "Failed to load proxies: {{error}}",
|
||||
"setupProxyListenersFailed": "Failed to setup proxy event listeners: {{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",
|
||||
"setProfilePasswordFailed": "Failed to set profile password: {{error}}"
|
||||
},
|
||||
"browser": {
|
||||
"camoufox": "Camoufox",
|
||||
@@ -1033,13 +1083,22 @@
|
||||
"lastLaunched": "Last Launched",
|
||||
"hostOs": "Host OS",
|
||||
"ephemeral": "Ephemeral",
|
||||
"extensionGroup": "Extension Group"
|
||||
"extensionGroup": "Extension Group",
|
||||
"totalSessions": "Total sessions",
|
||||
"syncMode": "Sync mode",
|
||||
"proxy": "PROXY",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Cookies stored",
|
||||
"localDataTransfer": "Local data transfer"
|
||||
},
|
||||
"values": {
|
||||
"none": "None",
|
||||
"never": "Never",
|
||||
"copied": "Copied!",
|
||||
"yes": "Yes"
|
||||
"yes": "Yes",
|
||||
"activeNow": "Active now",
|
||||
"direct": "Direct",
|
||||
"loading": "Loading…"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Proxy Bypass Rules",
|
||||
@@ -1053,8 +1112,9 @@
|
||||
"launchHook": {
|
||||
"title": "Launch Hook URL",
|
||||
"label": "Launch Hook URL",
|
||||
"description": "Donut Browser will POST to this URL whenever the profile is launched.",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
"description": "Donut Browser will send a GET request to this URL whenever the profile is launched.",
|
||||
"placeholder": "https://example.com/hooks/profile-launch",
|
||||
"invalidUrlHint": "Enter a valid http:// or https:// URL."
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Manage Cookies",
|
||||
@@ -1065,6 +1125,48 @@
|
||||
"description": "Enter a name for the cloned profile",
|
||||
"namePlaceholder": "Profile name",
|
||||
"button": "Clone"
|
||||
},
|
||||
"duplicate": "Duplicate",
|
||||
"breadcrumbRoot": "Profile",
|
||||
"openDialog": "Open settings",
|
||||
"sections": {
|
||||
"overview": "Overview",
|
||||
"fingerprint": "Fingerprint",
|
||||
"network": "Network",
|
||||
"cookies": "Cookies",
|
||||
"extensions": "Extensions",
|
||||
"sync": "Sync",
|
||||
"automation": "Automation",
|
||||
"security": "Security",
|
||||
"delete": "Delete profile",
|
||||
"activity": "Activity",
|
||||
"launchHook": "Launch hook"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Configure how this profile appears to fingerprinting scripts.",
|
||||
"network": "Manage the proxy or VPN this profile uses to reach the internet.",
|
||||
"cookies": "Import, copy, or wipe cookies for this profile.",
|
||||
"extensions": "Choose which extensions load when this profile launches.",
|
||||
"sync": "Configure how this profile is mirrored to your other devices.",
|
||||
"automation": "Run a command or script every time this profile launches.",
|
||||
"security": "Encrypt the profile data with a password.",
|
||||
"launchHook": "Send a GET request to this URL every time the profile launches."
|
||||
},
|
||||
"badges": {
|
||||
"locked": "LOCKED",
|
||||
"active": "ACTIVE"
|
||||
},
|
||||
"cookies": {
|
||||
"runningNotice": "Cookies can't be read while the browser is running. Close this profile first.",
|
||||
"domainsHeader": "Domains ({{count}})"
|
||||
},
|
||||
"security": {
|
||||
"protected": "This profile is encrypted with a password.",
|
||||
"unprotected": "This profile is not encrypted. Set a password to encrypt its data at rest.",
|
||||
"cannotWhileRunning": "Stop the profile before changing its password."
|
||||
},
|
||||
"fingerprint": {
|
||||
"notSupported": "Fingerprint editing is only available for Camoufox and Wayfern profiles."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1124,7 +1226,9 @@
|
||||
"syncEnabled": "Sync enabled",
|
||||
"syncDisabled": "Sync disabled",
|
||||
"syncEnableTooltip": "Enable sync",
|
||||
"syncDisableTooltip": "Disable sync"
|
||||
"syncDisableTooltip": "Disable sync",
|
||||
"loadGroupsFailed": "Failed to load extension groups",
|
||||
"assignGroupFailed": "Failed to assign extension group"
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
@@ -1256,12 +1360,11 @@
|
||||
"importedSuccess": "Successfully imported profile \"{{name}}\"",
|
||||
"notInstalled": "{{browser}} is not installed. Please download {{browser}} first from the main window, then try importing again.",
|
||||
"importFailed": "Failed to import profile: {{error}}",
|
||||
"importedAsPrefix": "This profile will be imported as a",
|
||||
"importedAsSuffix": "profile.",
|
||||
"proxyOptional": "Proxy (Optional)",
|
||||
"noProxy": "No proxy",
|
||||
"nextButton": "Next",
|
||||
"importButton": "Import"
|
||||
"importButton": "Import",
|
||||
"importedAs": "This profile will be imported as a {{browser}} profile."
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Syncing...",
|
||||
@@ -1424,7 +1527,11 @@
|
||||
"grantAccessButton": "Grant Access",
|
||||
"requestSuccessMicrophone": "Microphone Access permission requested",
|
||||
"requestSuccessCamera": "Camera Access permission requested",
|
||||
"requestFailed": "Failed to request permission"
|
||||
"requestFailed": "Failed to request permission",
|
||||
"stillNotGrantedMicrophone": "Microphone access still hasn't been granted. You may need to enable it manually in System Settings → Privacy & Security → Microphone.",
|
||||
"stillNotGrantedCamera": "Camera access still hasn't been granted. You may need to enable it manually in System Settings → Privacy & Security → Camera.",
|
||||
"grantedToastMicrophone": "Microphone access granted",
|
||||
"grantedToastCamera": "Camera access granted"
|
||||
},
|
||||
"traffic": {
|
||||
"title": "Traffic Details",
|
||||
@@ -1503,7 +1610,12 @@
|
||||
"syncTooltipNotSynced": "Not synced",
|
||||
"noTags": "No tags",
|
||||
"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": {
|
||||
"noReleaseTypes": "No release types available.",
|
||||
@@ -1521,7 +1633,14 @@
|
||||
"appUpdate": {
|
||||
"toast": {
|
||||
"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": {
|
||||
@@ -1532,7 +1651,10 @@
|
||||
"downloadFailed": "Failed to download {{browser}} {{version}}",
|
||||
"calculating": "calculating...",
|
||||
"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": {
|
||||
@@ -1552,5 +1674,156 @@
|
||||
"upToDateDescription": "All browser versions are up to date",
|
||||
"updateAllFailed": "Failed to update browser versions"
|
||||
}
|
||||
},
|
||||
"profilePassword": {
|
||||
"set": {
|
||||
"title": "Set Profile Password",
|
||||
"description": "Encrypt the on-disk data for {{name}}. You will need this password every time you launch the profile.",
|
||||
"button": "Encrypt Profile"
|
||||
},
|
||||
"unlock": {
|
||||
"title": "Unlock Profile",
|
||||
"description": "Enter the password to unlock {{name}}.",
|
||||
"button": "Unlock"
|
||||
},
|
||||
"change": {
|
||||
"title": "Change Profile Password",
|
||||
"description": "Re-encrypt {{name}} with a new password.",
|
||||
"button": "Change Password"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Remove Profile Password",
|
||||
"description": "Decrypt the on-disk data for {{name}}. The profile will no longer be password protected.",
|
||||
"button": "Remove Password"
|
||||
},
|
||||
"fields": {
|
||||
"password": "Password",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
"confirm": "Confirm password",
|
||||
"confirmPassword": "Confirm new password"
|
||||
},
|
||||
"errors": {
|
||||
"oldPasswordRequired": "Current password is required",
|
||||
"passwordRequired": "Password is required",
|
||||
"tooShort": "Password must be at least 8 characters",
|
||||
"mismatch": "Passwords do not match"
|
||||
},
|
||||
"toasts": {
|
||||
"set": "Profile is now password protected",
|
||||
"changed": "Profile password changed",
|
||||
"removed": "Profile password removed"
|
||||
},
|
||||
"warnings": {
|
||||
"forgetWarningTitle": "Important: this password is not recoverable",
|
||||
"forgetWarningBody": "Donut Browser cannot reset, recover, or bypass this password. If you forget it, you will permanently lose access to this profile's data."
|
||||
},
|
||||
"modes": {
|
||||
"set": "Set",
|
||||
"change": "Change",
|
||||
"remove": "Remove"
|
||||
}
|
||||
},
|
||||
"backendErrors": {
|
||||
"incorrectPassword": "Incorrect password",
|
||||
"lockedOut": "Too many incorrect attempts. Try again in {{duration}}.",
|
||||
"lockedOutDuration": {
|
||||
"seconds": "{{seconds}}s",
|
||||
"minutes": "{{minutes}} min",
|
||||
"hours": "{{hours}} h"
|
||||
},
|
||||
"profileNotFound": "Profile not found",
|
||||
"profileNotProtected": "Profile is not password protected",
|
||||
"profileAlreadyProtected": "Profile is already password protected",
|
||||
"profileRunning": "Cannot perform this action while the profile is running",
|
||||
"profileEphemeral": "Ephemeral profiles cannot be password-protected — their data wipes on quit.",
|
||||
"profileMissingSalt": "Profile is missing its encryption salt",
|
||||
"profileLocked": "Profile is locked. Enter the password first.",
|
||||
"invalidProfileId": "Invalid profile id",
|
||||
"passwordTooShort": "Password must be at least {{min}} characters",
|
||||
"internal": "Something went wrong: {{detail}}",
|
||||
"invalidLaunchHookUrl": "Invalid launch hook URL. Use a full http:// or https:// URL.",
|
||||
"cookieDbLocked": "Could not read cookies — the database is locked. Close the browser and try again.",
|
||||
"cookieDbUnavailable": "Could not read cookies — the cookie store is unavailable.",
|
||||
"selfHostedRequiresLogout": "Sign out of your Donut account before configuring a self-hosted server."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiles",
|
||||
"proxies": "Proxies",
|
||||
"extensions": "Extensions",
|
||||
"groups": "Groups",
|
||||
"settings": "Settings",
|
||||
"more": {
|
||||
"label": "More",
|
||||
"closeAriaLabel": "Close menu",
|
||||
"importProfile": "Import profile",
|
||||
"importProfileHint": "Bring profiles from another tool",
|
||||
"integrations": "Integrations",
|
||||
"integrationsHint": "Slack, MCP, automations",
|
||||
"account": "Account",
|
||||
"accountHint": "Cloud, billing, sign-in"
|
||||
}
|
||||
},
|
||||
"pageTitle": {
|
||||
"proxies": "Proxies",
|
||||
"extensions": "Extensions",
|
||||
"groups": "Groups",
|
||||
"vpns": "VPNs",
|
||||
"settings": "Settings",
|
||||
"integrations": "Integrations",
|
||||
"account": "Account",
|
||||
"import": "Import profile"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
"title": "Sync paused — password required",
|
||||
"description": "Encrypted data was downloaded but no E2E password is set on this device. Open Settings → Encryption and enter the password to resume sync.",
|
||||
"openSettings": "Open Settings"
|
||||
},
|
||||
"rollover": {
|
||||
"startedTitle": "Re-encrypting your data",
|
||||
"startedDescription": "We're re-uploading every synced item under the new password. Profiles first, then proxies, groups, VPNs, and extensions.",
|
||||
"progressTitle": "Re-encrypting {{stage}}",
|
||||
"progressDescription": "{{done}} of {{total}}",
|
||||
"completedTitle": "Re-encryption complete",
|
||||
"completedDescription": "All synced data is sealed under the new password.",
|
||||
"stage": {
|
||||
"profiles": "profiles",
|
||||
"proxies": "proxies",
|
||||
"groups": "groups",
|
||||
"vpns": "VPNs",
|
||||
"extensions": "extensions",
|
||||
"extension_groups": "extension groups"
|
||||
}
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"refreshed": "Account refreshed",
|
||||
"loggedOut": "Logged out",
|
||||
"signedOut": "Signed out",
|
||||
"signedOutDescription": "Sign in to enable cloud sync, encrypted profiles, and team features.",
|
||||
"plan": "Plan: {{plan}} · {{period}}",
|
||||
"refresh": "Refresh",
|
||||
"logout": "Sign out",
|
||||
"signIn": "Sign in",
|
||||
"fields": {
|
||||
"plan": "Plan",
|
||||
"status": "Status",
|
||||
"teamRole": "Team role",
|
||||
"period": "Billing period"
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Account",
|
||||
"selfHosted": "Self-hosted"
|
||||
},
|
||||
"selfHosted": {
|
||||
"title": "Self-hosted sync server",
|
||||
"description": "Point Donut at your own donut-sync server to sync profiles, proxies, groups, and extensions without using the hosted cloud.",
|
||||
"disabledWhileLoggedIn": "Self-hosted sync is unavailable while you're signed into your Donut account. Sign out to use a custom server.",
|
||||
"connectionStatus": "Connection:",
|
||||
"statusUnknown": "Untested",
|
||||
"testConnection": "Test connection",
|
||||
"disconnect": "Disconnect"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+316
-43
@@ -30,7 +30,9 @@
|
||||
"saveSettings": "Guardar Configuración",
|
||||
"moreInfo": "Más información",
|
||||
"downloading": "Descargando...",
|
||||
"minimize": "Minimizar"
|
||||
"minimize": "Minimizar",
|
||||
"saving": "Guardando…",
|
||||
"saved": "Guardado"
|
||||
},
|
||||
"status": {
|
||||
"active": "Activo",
|
||||
@@ -60,7 +62,8 @@
|
||||
"optional": "Opcional",
|
||||
"required": "Requerido",
|
||||
"unknownProfile": "Desconocido",
|
||||
"mode": "Modo"
|
||||
"mode": "Modo",
|
||||
"never": "Nunca"
|
||||
},
|
||||
"time": {
|
||||
"days": "días",
|
||||
@@ -72,7 +75,11 @@
|
||||
"aria": {
|
||||
"selectAll": "Seleccionar todo",
|
||||
"selectRow": "Seleccionar fila",
|
||||
"selectProfile": "Seleccionar perfil"
|
||||
"selectProfile": "Seleccionar perfil",
|
||||
"copy": "Copiar al portapapeles",
|
||||
"copied": "Copiado",
|
||||
"showToken": "Mostrar token",
|
||||
"hideToken": "Ocultar token"
|
||||
},
|
||||
"keys": {
|
||||
"escape": "Escape"
|
||||
@@ -87,7 +94,11 @@
|
||||
"title": "Paleta de comandos",
|
||||
"description": "Busca un comando para ejecutar..."
|
||||
},
|
||||
"noResults": "No se encontraron resultados."
|
||||
"noResults": "No se encontraron resultados.",
|
||||
"srOnly": {
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configuración",
|
||||
@@ -152,18 +163,26 @@
|
||||
"commercial": {
|
||||
"title": "Licencia Comercial",
|
||||
"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",
|
||||
"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.",
|
||||
"subscriptionActive": "Suscrito — plan {{plan}}",
|
||||
"subscriptionActiveDescription": "Tu suscripción a Donut Browser está activa. El uso comercial está autorizado mientras tu plan esté vigente."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Avanzado",
|
||||
"clearCache": "Limpiar Toda la Caché de Versiones",
|
||||
"clearCacheDescription": "Limpia todos los datos de versiones de navegadores en caché y actualiza todas las versiones desde sus fuentes. Esto forzará una descarga nueva de información de versiones para todos los navegadores.",
|
||||
"clearCacheFailed": "Error al limpiar la caché"
|
||||
"clearCacheFailed": "Error al limpiar la caché",
|
||||
"copyLogs": "Copiar registros",
|
||||
"openLogDir": "Abrir carpeta de registros",
|
||||
"copyLogsSuccess": "Registros copiados al portapapeles",
|
||||
"copyLogsDescription": "Une los archivos de registro más recientes (hasta 5 MB) en tu portapapeles para compartirlos en informes de error."
|
||||
},
|
||||
"disableAutoUpdates": "Desactivar Actualizaciones Automáticas de la App",
|
||||
"disableAutoUpdatesDescription": "Evita que la aplicación busque e instale actualizaciones de Donut Browser automáticamente. Las actualizaciones de navegadores no se ven afectadas."
|
||||
"disableAutoUpdatesDescription": "Evita que la aplicación busque e instale actualizaciones de Donut Browser automáticamente. Las actualizaciones de navegadores no se ven afectadas.",
|
||||
"keepDecryptedProfilesInRam": "Mantener Perfiles Descifrados en RAM",
|
||||
"keepDecryptedProfilesInRamDescription": "Conservar la copia descifrada en RAM de los perfiles protegidos por contraseña entre lanzamientos para un inicio más rápido. La copia en disco permanece cifrada en cualquier caso."
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Buscar perfiles...",
|
||||
@@ -178,7 +197,11 @@
|
||||
"integrations": "Integraciones",
|
||||
"importProfile": "Importar Perfil",
|
||||
"extensions": "Extensiones"
|
||||
}
|
||||
},
|
||||
"newProfile": "Nuevo",
|
||||
"donutLogo": "Logotipo de Donut Browser",
|
||||
"scrollGroupsLeft": "Desplazar grupos a la izquierda",
|
||||
"scrollGroupsRight": "Desplazar grupos a la derecha"
|
||||
},
|
||||
"profiles": {
|
||||
"title": "Perfiles",
|
||||
@@ -196,7 +219,14 @@
|
||||
"group": "Grupo",
|
||||
"proxy": "Proxy / VPN",
|
||||
"lastLaunch": "Último Inicio",
|
||||
"empty": "No se encontraron perfiles."
|
||||
"empty": "No se encontraron perfiles.",
|
||||
"notSelected": "No seleccionado",
|
||||
"ext": "EXT",
|
||||
"dns": "DNS",
|
||||
"extDefault": "Predet.",
|
||||
"dnsLevel": "Lista DNS: {{level}}",
|
||||
"extSearch": "Buscar grupos…",
|
||||
"extEmpty": "Sin grupos de extensiones"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Iniciar",
|
||||
@@ -211,7 +241,10 @@
|
||||
"assignToGroup": "Asignar a Grupo",
|
||||
"changeFingerprint": "Cambiar Huella Digital",
|
||||
"copyCookiesToProfile": "Copiar Cookies al Perfil",
|
||||
"launchHook": "URL del hook de inicio"
|
||||
"launchHook": "URL del hook de inicio",
|
||||
"setPassword": "Establecer Contraseña",
|
||||
"changePassword": "Cambiar Contraseña",
|
||||
"removePassword": "Quitar Contraseña"
|
||||
},
|
||||
"synchronizer": {
|
||||
"launchWithSync": "Lanzar con Sincronizador",
|
||||
@@ -230,9 +263,8 @@
|
||||
"flakyTooltip": "Este perfil tiene una resolución de pantalla diferente a la del líder. El diseño de las páginas puede variar, lo que puede causar que los clics e interacciones fallen."
|
||||
},
|
||||
"ephemeral": "Efímero",
|
||||
"ephemeralDescription": "El navegador es forzado a escribir los datos del perfil en memoria en lugar del disco. Los datos se eliminan al cerrar el navegador.",
|
||||
"ephemeralDescription": "El navegador se ve obligado a escribir los datos del perfil en memoria en lugar de en el disco. Ten en cuenta que tu sistema operativo puede pasar partes de la memoria al disco cuando hay poca RAM, por lo que aún podrían quedar rastros de la sesión recuperables.",
|
||||
"ephemeralBadge": "Efímero",
|
||||
"ephemeralAlpha": "Alpha",
|
||||
"bulkDelete": {
|
||||
"title": "Eliminar perfiles seleccionados",
|
||||
"description": "Esta acción no se puede deshacer. Eliminará permanentemente {{count}} perfil(es) y todos los datos asociados.",
|
||||
@@ -255,6 +287,10 @@
|
||||
"assignProxy": "Asignar proxy",
|
||||
"assignExtensionGroup": "Asignar grupo de extensiones",
|
||||
"copyCookies": "Copiar cookies"
|
||||
},
|
||||
"passwordProtectedBadge": "Protegido por Contraseña",
|
||||
"launchHook": {
|
||||
"placeholder": "https://example.com/track-launch"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -302,7 +338,11 @@
|
||||
"firefoxLabel": "Firefox",
|
||||
"firefoxSubtitle": "Impulsado por Camoufox",
|
||||
"camoufoxWarning": "Firefox (Camoufox) está mantenido por una organización de terceros. Para uso en producción, utilice Chromium.",
|
||||
"platformUnavailable": "{{browser}} aún no está disponible en tu plataforma."
|
||||
"platformUnavailable": "{{browser}} aún no está disponible en tu plataforma.",
|
||||
"passwordProtect": {
|
||||
"label": "Proteger este perfil con contraseña",
|
||||
"description": "Cifra los datos del perfil en disco. Necesario para abrirlo."
|
||||
}
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Eliminar Perfil",
|
||||
@@ -488,7 +528,9 @@
|
||||
"deleteGroupAndProfiles": "Eliminar Grupo y Perfiles",
|
||||
"loadProfilesFailed": "Error al cargar los perfiles",
|
||||
"unknownGroup": "Grupo desconocido",
|
||||
"profileGroupsAriaLabel": "Grupos de perfiles"
|
||||
"profileGroupsAriaLabel": "Grupos de perfiles",
|
||||
"loading": "Cargando grupos...",
|
||||
"all": "Todos"
|
||||
},
|
||||
"sync": {
|
||||
"mode": {
|
||||
@@ -540,6 +582,7 @@
|
||||
"openLogin": "Iniciar sesión",
|
||||
"linkCodeLabel": "Código de inicio de sesión",
|
||||
"linkCodePlaceholder": "Pega el código del sitio web",
|
||||
"signInTitle": "Iniciar Sesión",
|
||||
"verifyAndLogin": "Verificar e Iniciar Sesión",
|
||||
"loggingIn": "Iniciando sesión...",
|
||||
"connected": "Conectado",
|
||||
@@ -631,7 +674,8 @@
|
||||
"mcpAcceptTermsFirst": "(Acepta primero los términos de Wayfern en Configuración)",
|
||||
"mcpStarted": "Servidor MCP iniciado en puerto {{port}}",
|
||||
"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": {
|
||||
"title": "Importar Perfil",
|
||||
@@ -711,6 +755,10 @@
|
||||
"webrtc": "Bloquear WebRTC",
|
||||
"webgl": "Bloquear WebGL"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
"browserBehavior": "Comportamiento del navegador",
|
||||
"allowAddonsOpenTabs": "Permitir que los complementos abran nuevas pestañas automáticamente"
|
||||
}
|
||||
},
|
||||
"cookies": {
|
||||
@@ -875,7 +923,9 @@
|
||||
"loadProxiesFailed": "Error al cargar los proxies: {{error}}",
|
||||
"setupProxyListenersFailed": "Error al configurar los listeners de eventos de proxies: {{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",
|
||||
"setProfilePasswordFailed": "Error al establecer la contraseña del perfil: {{error}}"
|
||||
},
|
||||
"browser": {
|
||||
"camoufox": "Camoufox",
|
||||
@@ -901,15 +951,15 @@
|
||||
"blockWebRTC": "Bloquear WebRTC",
|
||||
"blockWebGL": "Bloquear WebGL",
|
||||
"navigatorProperties": "Propiedades del navegador",
|
||||
"userAgent": "User Agent",
|
||||
"userAgent": "Agente de usuario",
|
||||
"userAgentAndPlatform": "User Agent y plataforma",
|
||||
"platform": "Plataforma",
|
||||
"platformVersion": "Versión de plataforma",
|
||||
"appVersion": "Versión de la aplicación",
|
||||
"osCpu": "OS CPU",
|
||||
"osCpu": "CPU del SO",
|
||||
"hardwareConcurrency": "Concurrencia de hardware",
|
||||
"maxTouchPoints": "Puntos táctiles máximos",
|
||||
"doNotTrack": "Do Not Track",
|
||||
"doNotTrack": "No rastrear",
|
||||
"selectDntPlaceholder": "Seleccionar valor DNT",
|
||||
"dntAllowed": "0 (rastreo permitido)",
|
||||
"dntNotAllowed": "1 (rastreo no permitido)",
|
||||
@@ -931,8 +981,8 @@
|
||||
"outerHeight": "Alto exterior",
|
||||
"innerWidth": "Ancho interior",
|
||||
"innerHeight": "Alto interior",
|
||||
"screenX": "Screen X",
|
||||
"screenY": "Screen Y",
|
||||
"screenX": "Pantalla X",
|
||||
"screenY": "Pantalla Y",
|
||||
"geolocation": "Geolocalización",
|
||||
"timezoneAndGeolocation": "Zona horaria y geolocalización",
|
||||
"timezoneGeolocationDescription": "Estos valores anulan las APIs de zona horaria y geolocalización del navegador.",
|
||||
@@ -946,15 +996,15 @@
|
||||
"region": "Región",
|
||||
"script": "Script",
|
||||
"webglProperties": "Propiedades de WebGL",
|
||||
"webglVendor": "WebGL Vendor",
|
||||
"webglRenderer": "WebGL Renderer",
|
||||
"webglVendor": "Proveedor WebGL",
|
||||
"webglRenderer": "Renderizador WebGL",
|
||||
"webglParameters": "Parámetros de WebGL",
|
||||
"webglParametersJson": "Parámetros de WebGL (JSON)",
|
||||
"webgl2Parameters": "Parámetros de WebGL2",
|
||||
"webglShaderPrecisionFormats": "Formatos de precisión de WebGL Shader",
|
||||
"webgl2ShaderPrecisionFormats": "Formatos de precisión de WebGL2 Shader",
|
||||
"webglShaderPrecisionFormats": "Formatos de precisión de shader WebGL",
|
||||
"webgl2ShaderPrecisionFormats": "Formatos de precisión de shader WebGL2",
|
||||
"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.",
|
||||
"fonts": "Fuentes",
|
||||
"fontsJson": "Fuentes (JSON array)",
|
||||
@@ -975,8 +1025,8 @@
|
||||
"maxChannelCount": "Número máximo de canales",
|
||||
"vendorInfo": "Información del proveedor",
|
||||
"vendor": "Proveedor",
|
||||
"vendorSub": "Vendor Sub",
|
||||
"productSub": "Product Sub",
|
||||
"vendorSub": "Proveedor Sub",
|
||||
"productSub": "Producto Sub",
|
||||
"brand": "Marca",
|
||||
"brandVersion": "Versión de marca",
|
||||
"proFeature": "Esta es una función Pro",
|
||||
@@ -1033,13 +1083,22 @@
|
||||
"lastLaunched": "Último Lanzamiento",
|
||||
"hostOs": "SO Host",
|
||||
"ephemeral": "Efímero",
|
||||
"extensionGroup": "Grupo de Extensiones"
|
||||
"extensionGroup": "Grupo de Extensiones",
|
||||
"totalSessions": "Sesiones totales",
|
||||
"syncMode": "Modo de sinc.",
|
||||
"proxy": "PROXY",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Cookies guardadas",
|
||||
"localDataTransfer": "Transferencia de datos local"
|
||||
},
|
||||
"values": {
|
||||
"none": "Ninguno",
|
||||
"never": "Nunca",
|
||||
"copied": "¡Copiado!",
|
||||
"yes": "Sí"
|
||||
"yes": "Sí",
|
||||
"activeNow": "Activo ahora",
|
||||
"direct": "Directa",
|
||||
"loading": "Cargando…"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Reglas de Omisión de Proxy",
|
||||
@@ -1053,8 +1112,9 @@
|
||||
"launchHook": {
|
||||
"title": "URL del hook de inicio",
|
||||
"label": "URL del hook de inicio",
|
||||
"description": "Donut Browser enviará una solicitud POST a esta URL cada vez que se inicie el perfil.",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
"description": "Donut Browser enviará una solicitud GET a esta URL cada vez que se inicie el perfil.",
|
||||
"placeholder": "https://example.com/hooks/profile-launch",
|
||||
"invalidUrlHint": "Introduce una URL válida http:// o https://."
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Administrar Cookies",
|
||||
@@ -1065,6 +1125,48 @@
|
||||
"description": "Ingrese un nombre para el perfil clonado",
|
||||
"namePlaceholder": "Nombre del perfil",
|
||||
"button": "Clonar"
|
||||
},
|
||||
"duplicate": "Duplicar",
|
||||
"breadcrumbRoot": "Perfil",
|
||||
"openDialog": "Abrir ajustes",
|
||||
"sections": {
|
||||
"overview": "Resumen",
|
||||
"fingerprint": "Huella digital",
|
||||
"network": "Red",
|
||||
"cookies": "Cookies",
|
||||
"extensions": "Extensiones",
|
||||
"sync": "Sincronización",
|
||||
"automation": "Automatización",
|
||||
"security": "Seguridad",
|
||||
"delete": "Eliminar perfil",
|
||||
"activity": "Actividad",
|
||||
"launchHook": "Hook de inicio"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Configura cómo aparece este perfil para los scripts de fingerprinting.",
|
||||
"network": "Administra el proxy o la VPN que usa este perfil.",
|
||||
"cookies": "Importa, copia o borra cookies de este perfil.",
|
||||
"extensions": "Elige las extensiones que se cargan con este perfil.",
|
||||
"sync": "Configura cómo se replica este perfil entre tus dispositivos.",
|
||||
"automation": "Ejecuta un comando o script al iniciar este perfil.",
|
||||
"security": "Cifra los datos del perfil con una contraseña.",
|
||||
"launchHook": "Envía una solicitud GET a esta URL cada vez que se inicie el perfil."
|
||||
},
|
||||
"badges": {
|
||||
"locked": "BLOQUEADO",
|
||||
"active": "ACTIVO"
|
||||
},
|
||||
"cookies": {
|
||||
"runningNotice": "No se pueden leer las cookies mientras el navegador está en ejecución. Cierra este perfil primero.",
|
||||
"domainsHeader": "Dominios ({{count}})"
|
||||
},
|
||||
"security": {
|
||||
"protected": "Este perfil está cifrado con una contraseña.",
|
||||
"unprotected": "Este perfil no está cifrado. Establece una contraseña para cifrar sus datos.",
|
||||
"cannotWhileRunning": "Detén el perfil antes de cambiar su contraseña."
|
||||
},
|
||||
"fingerprint": {
|
||||
"notSupported": "La edición de huellas digitales solo está disponible para perfiles Camoufox y Wayfern."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1124,7 +1226,9 @@
|
||||
"syncEnabled": "Sincronización habilitada",
|
||||
"syncDisabled": "Sincronización deshabilitada",
|
||||
"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": {
|
||||
"badge": "PRO",
|
||||
@@ -1256,12 +1360,11 @@
|
||||
"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.",
|
||||
"importFailed": "Error al importar el perfil: {{error}}",
|
||||
"importedAsPrefix": "Este perfil se importará como un perfil de",
|
||||
"importedAsSuffix": ".",
|
||||
"proxyOptional": "Proxy (Opcional)",
|
||||
"noProxy": "Sin proxy",
|
||||
"nextButton": "Siguiente",
|
||||
"importButton": "Importar"
|
||||
"importButton": "Importar",
|
||||
"importedAs": "Este perfil se importará como un perfil de {{browser}}."
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Sincronizando...",
|
||||
@@ -1424,7 +1527,11 @@
|
||||
"grantAccessButton": "Conceder acceso",
|
||||
"requestSuccessMicrophone": "Acceso al micrófono solicitado",
|
||||
"requestSuccessCamera": "Acceso a la cámara solicitado",
|
||||
"requestFailed": "Error al solicitar el permiso"
|
||||
"requestFailed": "Error al solicitar el permiso",
|
||||
"stillNotGrantedMicrophone": "El acceso al micrófono aún no se ha concedido. Puede que tengas que habilitarlo manualmente en Configuración del Sistema → Privacidad y Seguridad → Micrófono.",
|
||||
"stillNotGrantedCamera": "El acceso a la cámara aún no se ha concedido. Puede que tengas que habilitarlo manualmente en Configuración del Sistema → Privacidad y Seguridad → Cámara.",
|
||||
"grantedToastMicrophone": "Acceso al micrófono concedido",
|
||||
"grantedToastCamera": "Acceso a la cámara concedido"
|
||||
},
|
||||
"traffic": {
|
||||
"title": "Detalles de tráfico",
|
||||
@@ -1497,13 +1604,18 @@
|
||||
"syncTooltipSyncing": "Sincronizando...",
|
||||
"syncTooltipSyncedAt": "Sincronizado {{time}}",
|
||||
"syncTooltipSynced": "Sincronizado",
|
||||
"syncTooltipWaiting": "Esperando sincronización",
|
||||
"syncTooltipWaiting": "Esperando para sincronizar",
|
||||
"syncTooltipErrorWith": "Error de sincronización: {{error}}",
|
||||
"syncTooltipError": "Error de sincronización",
|
||||
"syncTooltipNotSynced": "Sin sincronizar",
|
||||
"syncTooltipNotSynced": "No sincronizado",
|
||||
"noTags": "Sin etiquetas",
|
||||
"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": {
|
||||
"noReleaseTypes": "No hay tipos de versión disponibles.",
|
||||
@@ -1521,7 +1633,14 @@
|
||||
"appUpdate": {
|
||||
"toast": {
|
||||
"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": {
|
||||
@@ -1532,7 +1651,10 @@
|
||||
"downloadFailed": "Error al descargar {{browser}} {{version}}",
|
||||
"calculating": "calculando...",
|
||||
"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": {
|
||||
@@ -1552,5 +1674,156 @@
|
||||
"upToDateDescription": "Todas las versiones del navegador están actualizadas",
|
||||
"updateAllFailed": "Error al actualizar las versiones del navegador"
|
||||
}
|
||||
},
|
||||
"profilePassword": {
|
||||
"set": {
|
||||
"title": "Establecer Contraseña del Perfil",
|
||||
"description": "Cifra los datos en disco de {{name}}. Necesitarás esta contraseña cada vez que abras el perfil.",
|
||||
"button": "Cifrar Perfil"
|
||||
},
|
||||
"unlock": {
|
||||
"title": "Desbloquear Perfil",
|
||||
"description": "Introduce la contraseña para desbloquear {{name}}.",
|
||||
"button": "Desbloquear"
|
||||
},
|
||||
"change": {
|
||||
"title": "Cambiar Contraseña del Perfil",
|
||||
"description": "Vuelve a cifrar {{name}} con una nueva contraseña.",
|
||||
"button": "Cambiar Contraseña"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Quitar Contraseña del Perfil",
|
||||
"description": "Descifra los datos en disco de {{name}}. El perfil dejará de estar protegido por contraseña.",
|
||||
"button": "Quitar Contraseña"
|
||||
},
|
||||
"fields": {
|
||||
"password": "Contraseña",
|
||||
"currentPassword": "Contraseña actual",
|
||||
"newPassword": "Nueva contraseña",
|
||||
"confirm": "Confirmar contraseña",
|
||||
"confirmPassword": "Confirmar nueva contraseña"
|
||||
},
|
||||
"errors": {
|
||||
"oldPasswordRequired": "Se requiere la contraseña actual",
|
||||
"passwordRequired": "Se requiere la contraseña",
|
||||
"tooShort": "La contraseña debe tener al menos 8 caracteres",
|
||||
"mismatch": "Las contraseñas no coinciden"
|
||||
},
|
||||
"toasts": {
|
||||
"set": "El perfil ahora está protegido por contraseña",
|
||||
"changed": "Contraseña del perfil cambiada",
|
||||
"removed": "Contraseña del perfil eliminada"
|
||||
},
|
||||
"warnings": {
|
||||
"forgetWarningTitle": "Importante: esta contraseña no se puede recuperar",
|
||||
"forgetWarningBody": "Donut Browser no puede restablecer, recuperar ni omitir esta contraseña. Si la olvidas, perderás permanentemente el acceso a los datos de este perfil."
|
||||
},
|
||||
"modes": {
|
||||
"set": "Establecer",
|
||||
"change": "Cambiar",
|
||||
"remove": "Quitar"
|
||||
}
|
||||
},
|
||||
"backendErrors": {
|
||||
"incorrectPassword": "Contraseña incorrecta",
|
||||
"lockedOut": "Demasiados intentos incorrectos. Vuelve a intentar en {{duration}}.",
|
||||
"lockedOutDuration": {
|
||||
"seconds": "{{seconds}}s",
|
||||
"minutes": "{{minutes}} min",
|
||||
"hours": "{{hours}} h"
|
||||
},
|
||||
"profileNotFound": "Perfil no encontrado",
|
||||
"profileNotProtected": "El perfil no está protegido por contraseña",
|
||||
"profileAlreadyProtected": "El perfil ya está protegido por contraseña",
|
||||
"profileRunning": "No se puede realizar esta acción mientras el perfil está en ejecución",
|
||||
"profileEphemeral": "Los perfiles efímeros no pueden tener contraseña — sus datos se borran al salir.",
|
||||
"profileMissingSalt": "Al perfil le falta su sal de cifrado",
|
||||
"profileLocked": "El perfil está bloqueado. Introduce la contraseña primero.",
|
||||
"invalidProfileId": "ID de perfil no válido",
|
||||
"passwordTooShort": "La contraseña debe tener al menos {{min}} caracteres",
|
||||
"internal": "Algo salió mal: {{detail}}",
|
||||
"invalidLaunchHookUrl": "URL del hook de inicio no válida. Usa una URL completa http:// o https://.",
|
||||
"cookieDbLocked": "No se pudieron leer las cookies — la base de datos está bloqueada. Cierra el navegador e inténtalo de nuevo.",
|
||||
"cookieDbUnavailable": "No se pudieron leer las cookies — el almacén de cookies no está disponible.",
|
||||
"selfHostedRequiresLogout": "Cierra sesión en tu cuenta de Donut antes de configurar un servidor autoalojado."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfiles",
|
||||
"proxies": "Proxies",
|
||||
"extensions": "Extensiones",
|
||||
"groups": "Grupos",
|
||||
"settings": "Ajustes",
|
||||
"more": {
|
||||
"label": "Más",
|
||||
"closeAriaLabel": "Cerrar menú",
|
||||
"importProfile": "Importar perfil",
|
||||
"importProfileHint": "Trae perfiles de otra herramienta",
|
||||
"integrations": "Integraciones",
|
||||
"integrationsHint": "Slack, MCP, automatizaciones",
|
||||
"account": "Cuenta",
|
||||
"accountHint": "Nube, facturación, sesión"
|
||||
}
|
||||
},
|
||||
"pageTitle": {
|
||||
"proxies": "Proxies",
|
||||
"extensions": "Extensiones",
|
||||
"groups": "Grupos",
|
||||
"vpns": "VPN",
|
||||
"settings": "Ajustes",
|
||||
"integrations": "Integraciones",
|
||||
"account": "Cuenta",
|
||||
"import": "Importar perfil"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
"title": "Sincronización en pausa — contraseña requerida",
|
||||
"description": "Se descargaron datos cifrados pero no hay contraseña E2E configurada en este dispositivo. Abre Ajustes → Cifrado e introduce la contraseña para reanudar.",
|
||||
"openSettings": "Abrir ajustes"
|
||||
},
|
||||
"rollover": {
|
||||
"startedTitle": "Recifrando tus datos",
|
||||
"startedDescription": "Estamos volviendo a subir cada elemento sincronizado con la nueva contraseña. Primero los perfiles, luego proxies, grupos, VPN y extensiones.",
|
||||
"progressTitle": "Recifrando {{stage}}",
|
||||
"progressDescription": "{{done}} de {{total}}",
|
||||
"completedTitle": "Recifrado completo",
|
||||
"completedDescription": "Todos los datos sincronizados están sellados con la nueva contraseña.",
|
||||
"stage": {
|
||||
"profiles": "perfiles",
|
||||
"proxies": "proxies",
|
||||
"groups": "grupos",
|
||||
"vpns": "VPN",
|
||||
"extensions": "extensiones",
|
||||
"extension_groups": "grupos de extensiones"
|
||||
}
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"refreshed": "Cuenta actualizada",
|
||||
"loggedOut": "Sesión cerrada",
|
||||
"signedOut": "Sin sesión",
|
||||
"signedOutDescription": "Inicia sesión para activar la sincronización en la nube, perfiles cifrados y funciones de equipo.",
|
||||
"plan": "Plan: {{plan}} · {{period}}",
|
||||
"refresh": "Actualizar",
|
||||
"logout": "Cerrar sesión",
|
||||
"signIn": "Iniciar sesión",
|
||||
"fields": {
|
||||
"plan": "Plan",
|
||||
"status": "Estado",
|
||||
"teamRole": "Rol en el equipo",
|
||||
"period": "Período"
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Cuenta",
|
||||
"selfHosted": "Autoalojado"
|
||||
},
|
||||
"selfHosted": {
|
||||
"title": "Servidor de sincronización autoalojado",
|
||||
"description": "Conecta Donut a tu propio servidor donut-sync para sincronizar perfiles, proxies, grupos y extensiones sin usar la nube alojada.",
|
||||
"disabledWhileLoggedIn": "La sincronización autoalojada no está disponible mientras estás conectado a tu cuenta de Donut. Cierra sesión para usar un servidor personalizado.",
|
||||
"connectionStatus": "Conexión:",
|
||||
"statusUnknown": "Sin probar",
|
||||
"testConnection": "Probar conexión",
|
||||
"disconnect": "Desconectar"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+315
-42
@@ -30,7 +30,9 @@
|
||||
"saveSettings": "Enregistrer les paramètres",
|
||||
"moreInfo": "En savoir plus",
|
||||
"downloading": "Téléchargement...",
|
||||
"minimize": "Réduire"
|
||||
"minimize": "Réduire",
|
||||
"saving": "Enregistrement…",
|
||||
"saved": "Enregistré"
|
||||
},
|
||||
"status": {
|
||||
"active": "Actif",
|
||||
@@ -60,7 +62,8 @@
|
||||
"optional": "Optionnel",
|
||||
"required": "Requis",
|
||||
"unknownProfile": "Inconnu",
|
||||
"mode": "Mode"
|
||||
"mode": "Mode",
|
||||
"never": "Jamais"
|
||||
},
|
||||
"time": {
|
||||
"days": "jours",
|
||||
@@ -72,7 +75,11 @@
|
||||
"aria": {
|
||||
"selectAll": "Tout sélectionner",
|
||||
"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": {
|
||||
"escape": "Échap"
|
||||
@@ -87,7 +94,11 @@
|
||||
"title": "Palette de commandes",
|
||||
"description": "Rechercher une commande à exécuter..."
|
||||
},
|
||||
"noResults": "Aucun résultat trouvé."
|
||||
"noResults": "Aucun résultat trouvé.",
|
||||
"srOnly": {
|
||||
"copy": "Copier",
|
||||
"copied": "Copié"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
@@ -152,18 +163,26 @@
|
||||
"commercial": {
|
||||
"title": "Licence commerciale",
|
||||
"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é",
|
||||
"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.",
|
||||
"subscriptionActive": "Abonné — formule {{plan}}",
|
||||
"subscriptionActiveDescription": "Votre abonnement Donut Browser est actif. L'usage commercial est licencié pendant toute la durée de votre formule."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Avancé",
|
||||
"clearCache": "Effacer tout le cache des versions",
|
||||
"clearCacheDescription": "Efface toutes les données de versions de navigateurs en cache et actualise toutes les versions depuis leurs sources. Cela forcera un nouveau téléchargement des informations de version pour tous les navigateurs.",
|
||||
"clearCacheFailed": "Échec de la suppression du cache"
|
||||
"clearCacheFailed": "Échec de la suppression du cache",
|
||||
"copyLogs": "Copier les journaux",
|
||||
"openLogDir": "Ouvrir le dossier des journaux",
|
||||
"copyLogsSuccess": "Journaux copiés dans le presse-papiers",
|
||||
"copyLogsDescription": "Regroupe les derniers fichiers de journal (jusqu’à 5 Mo) dans votre presse-papiers pour les rapports de bug."
|
||||
},
|
||||
"disableAutoUpdates": "Désactiver les mises à jour automatiques de l'app",
|
||||
"disableAutoUpdatesDescription": "Empêche l'application de vérifier et d'installer automatiquement les mises à jour de Donut Browser. Les mises à jour des navigateurs ne sont pas affectées."
|
||||
"disableAutoUpdatesDescription": "Empêche l'application de vérifier et d'installer automatiquement les mises à jour de Donut Browser. Les mises à jour des navigateurs ne sont pas affectées.",
|
||||
"keepDecryptedProfilesInRam": "Conserver les profils déchiffrés en RAM",
|
||||
"keepDecryptedProfilesInRamDescription": "Conserver en RAM la copie déchiffrée des profils protégés par mot de passe entre les lancements pour un démarrage plus rapide. La copie sur disque reste chiffrée dans tous les cas."
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Rechercher des profils...",
|
||||
@@ -178,7 +197,11 @@
|
||||
"integrations": "Intégrations",
|
||||
"importProfile": "Importer un profil",
|
||||
"extensions": "Extensions"
|
||||
}
|
||||
},
|
||||
"newProfile": "Nouveau",
|
||||
"donutLogo": "Logo de Donut Browser",
|
||||
"scrollGroupsLeft": "Faire défiler les groupes vers la gauche",
|
||||
"scrollGroupsRight": "Faire défiler les groupes vers la droite"
|
||||
},
|
||||
"profiles": {
|
||||
"title": "Profils",
|
||||
@@ -196,7 +219,14 @@
|
||||
"group": "Groupe",
|
||||
"proxy": "Proxy / VPN",
|
||||
"lastLaunch": "Dernier lancement",
|
||||
"empty": "Aucun profil trouvé."
|
||||
"empty": "Aucun profil trouvé.",
|
||||
"notSelected": "Non sélectionné",
|
||||
"ext": "EXT",
|
||||
"dns": "DNS",
|
||||
"extDefault": "Défaut",
|
||||
"dnsLevel": "Liste DNS : {{level}}",
|
||||
"extSearch": "Rechercher des groupes…",
|
||||
"extEmpty": "Aucun groupe d’extensions"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Lancer",
|
||||
@@ -211,7 +241,10 @@
|
||||
"assignToGroup": "Assigner au Groupe",
|
||||
"changeFingerprint": "Changer l'Empreinte",
|
||||
"copyCookiesToProfile": "Copier les Cookies vers le Profil",
|
||||
"launchHook": "URL du hook de lancement"
|
||||
"launchHook": "URL du hook de lancement",
|
||||
"setPassword": "Définir un mot de passe",
|
||||
"changePassword": "Changer le mot de passe",
|
||||
"removePassword": "Supprimer le mot de passe"
|
||||
},
|
||||
"synchronizer": {
|
||||
"launchWithSync": "Lancer avec le synchroniseur",
|
||||
@@ -230,9 +263,8 @@
|
||||
"flakyTooltip": "Ce profil a une résolution d'écran différente de celle du leader. La mise en page des pages peut différer, ce qui peut causer des clics et interactions erronés."
|
||||
},
|
||||
"ephemeral": "Éphémère",
|
||||
"ephemeralDescription": "Le navigateur est forcé d'écrire les données du profil en mémoire au lieu du disque. Les données sont supprimées à la fermeture du navigateur.",
|
||||
"ephemeralDescription": "Le navigateur est contraint d'écrire les données du profil en mémoire plutôt que sur le disque. Notez que votre système d'exploitation peut écrire une partie de la mémoire sur le disque (swap) en cas de charge, donc des traces de la session pourraient rester récupérables.",
|
||||
"ephemeralBadge": "Éphémère",
|
||||
"ephemeralAlpha": "Alpha",
|
||||
"bulkDelete": {
|
||||
"title": "Supprimer les profils sélectionnés",
|
||||
"description": "Cette action est irréversible. Elle supprimera définitivement {{count}} profil(s) et toutes les données associées.",
|
||||
@@ -255,6 +287,10 @@
|
||||
"assignProxy": "Assigner un proxy",
|
||||
"assignExtensionGroup": "Assigner un groupe d’extensions",
|
||||
"copyCookies": "Copier les cookies"
|
||||
},
|
||||
"passwordProtectedBadge": "Protégé par mot de passe",
|
||||
"launchHook": {
|
||||
"placeholder": "https://example.com/track-launch"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -302,7 +338,11 @@
|
||||
"firefoxLabel": "Firefox",
|
||||
"firefoxSubtitle": "Propulsé par Camoufox",
|
||||
"camoufoxWarning": "Firefox (Camoufox) est maintenu par une organisation tierce. Pour une utilisation en production, veuillez utiliser Chromium.",
|
||||
"platformUnavailable": "{{browser}} n'est pas encore disponible sur votre plateforme."
|
||||
"platformUnavailable": "{{browser}} n'est pas encore disponible sur votre plateforme.",
|
||||
"passwordProtect": {
|
||||
"label": "Protéger ce profil par mot de passe",
|
||||
"description": "Chiffre les données du profil sur disque. Requis au lancement."
|
||||
}
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Supprimer le profil",
|
||||
@@ -488,7 +528,9 @@
|
||||
"deleteGroupAndProfiles": "Supprimer le Groupe et les Profils",
|
||||
"loadProfilesFailed": "Échec du chargement des profils",
|
||||
"unknownGroup": "Groupe inconnu",
|
||||
"profileGroupsAriaLabel": "Groupes de profils"
|
||||
"profileGroupsAriaLabel": "Groupes de profils",
|
||||
"loading": "Chargement des groupes...",
|
||||
"all": "Tous"
|
||||
},
|
||||
"sync": {
|
||||
"mode": {
|
||||
@@ -540,6 +582,7 @@
|
||||
"openLogin": "Se connecter",
|
||||
"linkCodeLabel": "Code de connexion",
|
||||
"linkCodePlaceholder": "Collez le code du site web",
|
||||
"signInTitle": "Se Connecter",
|
||||
"verifyAndLogin": "Vérifier et Se Connecter",
|
||||
"loggingIn": "Connexion en cours...",
|
||||
"connected": "Connecté",
|
||||
@@ -631,7 +674,8 @@
|
||||
"mcpAcceptTermsFirst": "(Acceptez d'abord les conditions Wayfern dans les Paramètres)",
|
||||
"mcpStarted": "Serveur MCP démarré sur le port {{port}}",
|
||||
"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": {
|
||||
"title": "Importer un profil",
|
||||
@@ -711,6 +755,10 @@
|
||||
"webrtc": "Bloquer WebRTC",
|
||||
"webgl": "Bloquer WebGL"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
"browserBehavior": "Comportement du navigateur",
|
||||
"allowAddonsOpenTabs": "Autoriser les modules complémentaires à ouvrir automatiquement de nouveaux onglets"
|
||||
}
|
||||
},
|
||||
"cookies": {
|
||||
@@ -875,7 +923,9 @@
|
||||
"loadProxiesFailed": "Échec du chargement des proxies : {{error}}",
|
||||
"setupProxyListenersFailed": "Échec de la configuration des écouteurs d’événements de proxies : {{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",
|
||||
"setProfilePasswordFailed": "Échec de la définition du mot de passe du profil : {{error}}"
|
||||
},
|
||||
"browser": {
|
||||
"camoufox": "Camoufox",
|
||||
@@ -906,10 +956,10 @@
|
||||
"platform": "Plateforme",
|
||||
"platformVersion": "Version de la plateforme",
|
||||
"appVersion": "Version de l'application",
|
||||
"osCpu": "OS CPU",
|
||||
"osCpu": "CPU OS",
|
||||
"hardwareConcurrency": "Concurrence matérielle",
|
||||
"maxTouchPoints": "Points tactiles maximum",
|
||||
"doNotTrack": "Do Not Track",
|
||||
"doNotTrack": "Ne pas suivre",
|
||||
"selectDntPlaceholder": "Sélectionner la valeur DNT",
|
||||
"dntAllowed": "0 (suivi autorisé)",
|
||||
"dntNotAllowed": "1 (suivi non autorisé)",
|
||||
@@ -931,8 +981,8 @@
|
||||
"outerHeight": "Hauteur extérieure",
|
||||
"innerWidth": "Largeur intérieure",
|
||||
"innerHeight": "Hauteur intérieure",
|
||||
"screenX": "Screen X",
|
||||
"screenY": "Screen Y",
|
||||
"screenX": "Écran X",
|
||||
"screenY": "Écran Y",
|
||||
"geolocation": "Géolocalisation",
|
||||
"timezoneAndGeolocation": "Fuseau horaire et géolocalisation",
|
||||
"timezoneGeolocationDescription": "Ces valeurs remplacent les APIs de fuseau horaire et de géolocalisation du navigateur.",
|
||||
@@ -946,15 +996,15 @@
|
||||
"region": "Région",
|
||||
"script": "Script",
|
||||
"webglProperties": "Propriétés WebGL",
|
||||
"webglVendor": "WebGL Vendor",
|
||||
"webglRenderer": "WebGL Renderer",
|
||||
"webglVendor": "Fournisseur WebGL",
|
||||
"webglRenderer": "Moteur de rendu WebGL",
|
||||
"webglParameters": "Paramètres WebGL",
|
||||
"webglParametersJson": "Paramètres WebGL (JSON)",
|
||||
"webgl2Parameters": "Paramètres WebGL2",
|
||||
"webglShaderPrecisionFormats": "Formats de précision WebGL Shader",
|
||||
"webgl2ShaderPrecisionFormats": "Formats de précision WebGL2 Shader",
|
||||
"webglShaderPrecisionFormats": "Formats de précision shader WebGL",
|
||||
"webgl2ShaderPrecisionFormats": "Formats de précision shader WebGL2",
|
||||
"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.",
|
||||
"fonts": "Polices",
|
||||
"fontsJson": "Polices (JSON array)",
|
||||
@@ -975,8 +1025,8 @@
|
||||
"maxChannelCount": "Nombre maximum de canaux",
|
||||
"vendorInfo": "Informations du fournisseur",
|
||||
"vendor": "Fournisseur",
|
||||
"vendorSub": "Vendor Sub",
|
||||
"productSub": "Product Sub",
|
||||
"vendorSub": "Fournisseur Sub",
|
||||
"productSub": "Produit Sub",
|
||||
"brand": "Marque",
|
||||
"brandVersion": "Version de la marque",
|
||||
"proFeature": "Ceci est une fonctionnalité Pro",
|
||||
@@ -1033,13 +1083,22 @@
|
||||
"lastLaunched": "Dernier Lancement",
|
||||
"hostOs": "OS Hôte",
|
||||
"ephemeral": "Éphémère",
|
||||
"extensionGroup": "Groupe d'Extensions"
|
||||
"extensionGroup": "Groupe d'Extensions",
|
||||
"totalSessions": "Sessions totales",
|
||||
"syncMode": "Mode de sync.",
|
||||
"proxy": "PROXY",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Cookies stockés",
|
||||
"localDataTransfer": "Transfert de données local"
|
||||
},
|
||||
"values": {
|
||||
"none": "Aucun",
|
||||
"never": "Jamais",
|
||||
"copied": "Copié !",
|
||||
"yes": "Oui"
|
||||
"yes": "Oui",
|
||||
"activeNow": "Actif maintenant",
|
||||
"direct": "Direct",
|
||||
"loading": "Chargement…"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Règles de Contournement du Proxy",
|
||||
@@ -1053,8 +1112,9 @@
|
||||
"launchHook": {
|
||||
"title": "URL du hook de lancement",
|
||||
"label": "URL du hook de lancement",
|
||||
"description": "Donut Browser enverra une requête POST à cette URL chaque fois que le profil est lancé.",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
"description": "Donut Browser enverra une requête GET à cette URL à chaque lancement du profil.",
|
||||
"placeholder": "https://example.com/hooks/profile-launch",
|
||||
"invalidUrlHint": "Saisissez une URL valide http:// ou https://."
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Gérer les Cookies",
|
||||
@@ -1065,6 +1125,48 @@
|
||||
"description": "Entrez un nom pour le profil cloné",
|
||||
"namePlaceholder": "Nom du profil",
|
||||
"button": "Cloner"
|
||||
},
|
||||
"duplicate": "Dupliquer",
|
||||
"breadcrumbRoot": "Profil",
|
||||
"openDialog": "Ouvrir les paramètres",
|
||||
"sections": {
|
||||
"overview": "Aperçu",
|
||||
"fingerprint": "Empreinte",
|
||||
"network": "Réseau",
|
||||
"cookies": "Cookies",
|
||||
"extensions": "Extensions",
|
||||
"sync": "Synchronisation",
|
||||
"automation": "Automatisation",
|
||||
"security": "Sécurité",
|
||||
"delete": "Supprimer le profil",
|
||||
"activity": "Activité",
|
||||
"launchHook": "Hook de lancement"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Configurez l’apparence de ce profil pour les scripts de fingerprinting.",
|
||||
"network": "Gérez le proxy ou le VPN utilisé par ce profil.",
|
||||
"cookies": "Importer, copier ou effacer les cookies de ce profil.",
|
||||
"extensions": "Choisissez les extensions à charger avec ce profil.",
|
||||
"sync": "Configurez la réplication de ce profil entre vos appareils.",
|
||||
"automation": "Exécutez une commande au lancement de ce profil.",
|
||||
"security": "Chiffrez les données du profil avec un mot de passe.",
|
||||
"launchHook": "Envoie une requête GET à cette URL à chaque lancement du profil."
|
||||
},
|
||||
"badges": {
|
||||
"locked": "VERROUILLÉ",
|
||||
"active": "ACTIF"
|
||||
},
|
||||
"cookies": {
|
||||
"runningNotice": "Impossible de lire les cookies pendant l'exécution du navigateur. Fermez d'abord ce profil.",
|
||||
"domainsHeader": "Domaines ({{count}})"
|
||||
},
|
||||
"security": {
|
||||
"protected": "Ce profil est chiffré par mot de passe.",
|
||||
"unprotected": "Ce profil n’est pas chiffré. Définissez un mot de passe pour chiffrer ses données.",
|
||||
"cannotWhileRunning": "Arrêtez le profil avant de modifier son mot de passe."
|
||||
},
|
||||
"fingerprint": {
|
||||
"notSupported": "L’édition des empreintes n’est disponible que pour les profils Camoufox et Wayfern."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1124,7 +1226,9 @@
|
||||
"syncEnabled": "Synchronisation activée",
|
||||
"syncDisabled": "Synchronisation désactivée",
|
||||
"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": {
|
||||
"badge": "PRO",
|
||||
@@ -1256,12 +1360,11 @@
|
||||
"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.",
|
||||
"importFailed": "Échec de l'import du profil : {{error}}",
|
||||
"importedAsPrefix": "Ce profil sera importé en tant que profil",
|
||||
"importedAsSuffix": ".",
|
||||
"proxyOptional": "Proxy (optionnel)",
|
||||
"noProxy": "Aucun proxy",
|
||||
"nextButton": "Suivant",
|
||||
"importButton": "Importer"
|
||||
"importButton": "Importer",
|
||||
"importedAs": "Ce profil sera importé en tant que profil {{browser}}."
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Synchronisation...",
|
||||
@@ -1424,7 +1527,11 @@
|
||||
"grantAccessButton": "Accorder l'accès",
|
||||
"requestSuccessMicrophone": "Accès au microphone demandé",
|
||||
"requestSuccessCamera": "Accès à la caméra demandé",
|
||||
"requestFailed": "Échec de la demande de permission"
|
||||
"requestFailed": "Échec de la demande de permission",
|
||||
"stillNotGrantedMicrophone": "L'accès au microphone n'a toujours pas été accordé. Vous devrez peut-être l'activer manuellement dans Réglages Système → Confidentialité et sécurité → Microphone.",
|
||||
"stillNotGrantedCamera": "L'accès à la caméra n'a toujours pas été accordé. Vous devrez peut-être l'activer manuellement dans Réglages Système → Confidentialité et sécurité → Caméra.",
|
||||
"grantedToastMicrophone": "Accès au microphone accordé",
|
||||
"grantedToastCamera": "Accès à la caméra accordé"
|
||||
},
|
||||
"traffic": {
|
||||
"title": "Détails du trafic",
|
||||
@@ -1497,13 +1604,18 @@
|
||||
"syncTooltipSyncing": "Synchronisation...",
|
||||
"syncTooltipSyncedAt": "Synchronisé {{time}}",
|
||||
"syncTooltipSynced": "Synchronisé",
|
||||
"syncTooltipWaiting": "En attente de sync",
|
||||
"syncTooltipWaiting": "En attente de synchronisation",
|
||||
"syncTooltipErrorWith": "Erreur de sync : {{error}}",
|
||||
"syncTooltipError": "Erreur de sync",
|
||||
"syncTooltipError": "Erreur de synchronisation",
|
||||
"syncTooltipNotSynced": "Non synchronisé",
|
||||
"noTags": "Aucune étiquette",
|
||||
"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": {
|
||||
"noReleaseTypes": "Aucun type de version disponible.",
|
||||
@@ -1521,7 +1633,14 @@
|
||||
"appUpdate": {
|
||||
"toast": {
|
||||
"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": {
|
||||
@@ -1532,7 +1651,10 @@
|
||||
"downloadFailed": "Échec du téléchargement de {{browser}} {{version}}",
|
||||
"calculating": "calcul en cours...",
|
||||
"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": {
|
||||
@@ -1552,5 +1674,156 @@
|
||||
"upToDateDescription": "Toutes les versions des navigateurs sont à jour",
|
||||
"updateAllFailed": "Échec de la mise à jour des versions des navigateurs"
|
||||
}
|
||||
},
|
||||
"profilePassword": {
|
||||
"set": {
|
||||
"title": "Définir un mot de passe de profil",
|
||||
"description": "Chiffre les données sur disque de {{name}}. Vous devrez saisir ce mot de passe à chaque lancement du profil.",
|
||||
"button": "Chiffrer le profil"
|
||||
},
|
||||
"unlock": {
|
||||
"title": "Déverrouiller le profil",
|
||||
"description": "Saisissez le mot de passe pour déverrouiller {{name}}.",
|
||||
"button": "Déverrouiller"
|
||||
},
|
||||
"change": {
|
||||
"title": "Changer le mot de passe du profil",
|
||||
"description": "Re-chiffre {{name}} avec un nouveau mot de passe.",
|
||||
"button": "Changer le mot de passe"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Supprimer le mot de passe du profil",
|
||||
"description": "Déchiffre les données sur disque de {{name}}. Le profil ne sera plus protégé par mot de passe.",
|
||||
"button": "Supprimer le mot de passe"
|
||||
},
|
||||
"fields": {
|
||||
"password": "Mot de passe",
|
||||
"currentPassword": "Mot de passe actuel",
|
||||
"newPassword": "Nouveau mot de passe",
|
||||
"confirm": "Confirmer le mot de passe",
|
||||
"confirmPassword": "Confirmer le nouveau mot de passe"
|
||||
},
|
||||
"errors": {
|
||||
"oldPasswordRequired": "Le mot de passe actuel est requis",
|
||||
"passwordRequired": "Le mot de passe est requis",
|
||||
"tooShort": "Le mot de passe doit comporter au moins 8 caractères",
|
||||
"mismatch": "Les mots de passe ne correspondent pas"
|
||||
},
|
||||
"toasts": {
|
||||
"set": "Le profil est maintenant protégé par mot de passe",
|
||||
"changed": "Mot de passe du profil modifié",
|
||||
"removed": "Mot de passe du profil supprimé"
|
||||
},
|
||||
"warnings": {
|
||||
"forgetWarningTitle": "Important : ce mot de passe ne peut pas être récupéré",
|
||||
"forgetWarningBody": "Donut Browser ne peut ni réinitialiser, ni récupérer, ni contourner ce mot de passe. Si vous l'oubliez, vous perdrez définitivement l'accès aux données de ce profil."
|
||||
},
|
||||
"modes": {
|
||||
"set": "Définir",
|
||||
"change": "Modifier",
|
||||
"remove": "Supprimer"
|
||||
}
|
||||
},
|
||||
"backendErrors": {
|
||||
"incorrectPassword": "Mot de passe incorrect",
|
||||
"lockedOut": "Trop de tentatives incorrectes. Réessayez dans {{duration}}.",
|
||||
"lockedOutDuration": {
|
||||
"seconds": "{{seconds}}s",
|
||||
"minutes": "{{minutes}} min",
|
||||
"hours": "{{hours}} h"
|
||||
},
|
||||
"profileNotFound": "Profil introuvable",
|
||||
"profileNotProtected": "Le profil n'est pas protégé par mot de passe",
|
||||
"profileAlreadyProtected": "Le profil est déjà protégé par mot de passe",
|
||||
"profileRunning": "Impossible d'effectuer cette action pendant que le profil est en cours d'exécution",
|
||||
"profileEphemeral": "Les profils éphémères ne peuvent pas être protégés par mot de passe — leurs données s'effacent à la fermeture.",
|
||||
"profileMissingSalt": "Le sel de chiffrement du profil est manquant",
|
||||
"profileLocked": "Le profil est verrouillé. Entrez d'abord le mot de passe.",
|
||||
"invalidProfileId": "Identifiant de profil non valide",
|
||||
"passwordTooShort": "Le mot de passe doit comporter au moins {{min}} caractères",
|
||||
"internal": "Une erreur s'est produite : {{detail}}",
|
||||
"invalidLaunchHookUrl": "URL du hook de lancement invalide. Utilisez une URL http:// ou https:// complète.",
|
||||
"cookieDbLocked": "Impossible de lire les cookies — la base de données est verrouillée. Fermez le navigateur et réessayez.",
|
||||
"cookieDbUnavailable": "Impossible de lire les cookies — le magasin de cookies est indisponible.",
|
||||
"selfHostedRequiresLogout": "Déconnectez-vous de votre compte Donut avant de configurer un serveur auto-hébergé."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profils",
|
||||
"proxies": "Proxys",
|
||||
"extensions": "Extensions",
|
||||
"groups": "Groupes",
|
||||
"settings": "Paramètres",
|
||||
"more": {
|
||||
"label": "Plus",
|
||||
"closeAriaLabel": "Fermer le menu",
|
||||
"importProfile": "Importer un profil",
|
||||
"importProfileHint": "Importer depuis un autre outil",
|
||||
"integrations": "Intégrations",
|
||||
"integrationsHint": "Slack, MCP, automatisations",
|
||||
"account": "Compte",
|
||||
"accountHint": "Cloud, facturation, connexion"
|
||||
}
|
||||
},
|
||||
"pageTitle": {
|
||||
"proxies": "Proxys",
|
||||
"extensions": "Extensions",
|
||||
"groups": "Groupes",
|
||||
"vpns": "VPN",
|
||||
"settings": "Paramètres",
|
||||
"integrations": "Intégrations",
|
||||
"account": "Compte",
|
||||
"import": "Importer un profil"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
"title": "Synchronisation en pause — mot de passe requis",
|
||||
"description": "Des données chiffrées ont été téléchargées mais aucun mot de passe E2E n'est défini sur cet appareil. Ouvrez Paramètres → Chiffrement et entrez le mot de passe pour reprendre.",
|
||||
"openSettings": "Ouvrir les paramètres"
|
||||
},
|
||||
"rollover": {
|
||||
"startedTitle": "Rechiffrement de vos données",
|
||||
"startedDescription": "Nous réuploadons chaque élément synchronisé avec le nouveau mot de passe. Profils d'abord, puis proxys, groupes, VPN et extensions.",
|
||||
"progressTitle": "Rechiffrement {{stage}}",
|
||||
"progressDescription": "{{done}} sur {{total}}",
|
||||
"completedTitle": "Rechiffrement terminé",
|
||||
"completedDescription": "Toutes les données synchronisées sont scellées avec le nouveau mot de passe.",
|
||||
"stage": {
|
||||
"profiles": "profils",
|
||||
"proxies": "proxys",
|
||||
"groups": "groupes",
|
||||
"vpns": "VPN",
|
||||
"extensions": "extensions",
|
||||
"extension_groups": "groupes d'extensions"
|
||||
}
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"refreshed": "Compte actualisé",
|
||||
"loggedOut": "Déconnecté",
|
||||
"signedOut": "Déconnecté",
|
||||
"signedOutDescription": "Connectez-vous pour activer la synchronisation cloud, les profils chiffrés et les fonctionnalités d’équipe.",
|
||||
"plan": "Plan : {{plan}} · {{period}}",
|
||||
"refresh": "Actualiser",
|
||||
"logout": "Se déconnecter",
|
||||
"signIn": "Se connecter",
|
||||
"fields": {
|
||||
"plan": "Plan",
|
||||
"status": "Statut",
|
||||
"teamRole": "Rôle d’équipe",
|
||||
"period": "Période"
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Compte",
|
||||
"selfHosted": "Auto-hébergé"
|
||||
},
|
||||
"selfHosted": {
|
||||
"title": "Serveur de synchronisation auto-hébergé",
|
||||
"description": "Connectez Donut à votre propre serveur donut-sync pour synchroniser profils, proxys, groupes et extensions sans utiliser le cloud hébergé.",
|
||||
"disabledWhileLoggedIn": "La synchronisation auto-hébergée n'est pas disponible lorsque vous êtes connecté à votre compte Donut. Déconnectez-vous pour utiliser un serveur personnalisé.",
|
||||
"connectionStatus": "Connexion :",
|
||||
"statusUnknown": "Non testé",
|
||||
"testConnection": "Tester la connexion",
|
||||
"disconnect": "Déconnecter"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+313
-40
@@ -30,7 +30,9 @@
|
||||
"saveSettings": "設定を保存",
|
||||
"moreInfo": "詳細",
|
||||
"downloading": "ダウンロード中...",
|
||||
"minimize": "最小化"
|
||||
"minimize": "最小化",
|
||||
"saving": "保存中…",
|
||||
"saved": "保存しました"
|
||||
},
|
||||
"status": {
|
||||
"active": "アクティブ",
|
||||
@@ -60,7 +62,8 @@
|
||||
"optional": "任意",
|
||||
"required": "必須",
|
||||
"unknownProfile": "不明",
|
||||
"mode": "モード"
|
||||
"mode": "モード",
|
||||
"never": "一度もありません"
|
||||
},
|
||||
"time": {
|
||||
"days": "日",
|
||||
@@ -72,7 +75,11 @@
|
||||
"aria": {
|
||||
"selectAll": "すべて選択",
|
||||
"selectRow": "行を選択",
|
||||
"selectProfile": "プロファイルを選択"
|
||||
"selectProfile": "プロファイルを選択",
|
||||
"copy": "クリップボードにコピー",
|
||||
"copied": "コピーしました",
|
||||
"showToken": "トークンを表示",
|
||||
"hideToken": "トークンを非表示"
|
||||
},
|
||||
"keys": {
|
||||
"escape": "Esc"
|
||||
@@ -87,7 +94,11 @@
|
||||
"title": "コマンドパレット",
|
||||
"description": "実行するコマンドを検索..."
|
||||
},
|
||||
"noResults": "結果が見つかりません。"
|
||||
"noResults": "結果が見つかりません。",
|
||||
"srOnly": {
|
||||
"copy": "コピー",
|
||||
"copied": "コピーしました"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
@@ -152,18 +163,26 @@
|
||||
"commercial": {
|
||||
"title": "商用ライセンス",
|
||||
"trialActive": "トライアル: 残り {{days}} 日 {{hours}} 時間",
|
||||
"trialActiveDescription": "トライアル期間中は商用利用が無料です",
|
||||
"trialActiveDescription": "トライアル期間中は商用利用が無料です。期間が終了してもすべての機能はそのまま使用できます — 個人利用は引き続き無料で、商用利用のみライセンスが必要になります。",
|
||||
"trialExpired": "トライアル期限切れ",
|
||||
"trialExpiredDescription": "個人利用は引き続き無料です。商用利用にはライセンスが必要です。"
|
||||
"trialExpiredDescription": "個人利用は引き続き無料です。商用利用にはライセンスが必要です。",
|
||||
"subscriptionActive": "サブスクリプション中 — {{plan}} プラン",
|
||||
"subscriptionActiveDescription": "Donut Browser のサブスクリプションが有効です。プランの期間中、商用利用がライセンスされます。"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "詳細設定",
|
||||
"clearCache": "すべてのバージョンキャッシュをクリア",
|
||||
"clearCacheDescription": "キャッシュされたすべてのブラウザバージョンデータをクリアし、すべてのブラウザバージョンをソースから更新します。これにより、すべてのブラウザのバージョン情報が強制的に再ダウンロードされます。",
|
||||
"clearCacheFailed": "キャッシュのクリアに失敗しました"
|
||||
"clearCacheFailed": "キャッシュのクリアに失敗しました",
|
||||
"copyLogs": "ログをコピー",
|
||||
"openLogDir": "ログフォルダを開く",
|
||||
"copyLogsSuccess": "ログをクリップボードにコピーしました",
|
||||
"copyLogsDescription": "最新のログファイル(最大 5 MB)をクリップボードにまとめ、不具合報告で共有できるようにします。"
|
||||
},
|
||||
"disableAutoUpdates": "アプリの自動更新を無効にする",
|
||||
"disableAutoUpdatesDescription": "Donut Browserの自動更新確認・インストールを無効にします。ブラウザの更新には影響しません。"
|
||||
"disableAutoUpdatesDescription": "Donut Browserの自動更新確認・インストールを無効にします。ブラウザの更新には影響しません。",
|
||||
"keepDecryptedProfilesInRam": "復号済みプロファイルをRAMに保持",
|
||||
"keepDecryptedProfilesInRamDescription": "起動を高速化するため、パスワード保護されたプロファイルの復号済みコピーをRAMに保持します。ディスク上のコピーは常に暗号化されたままです。"
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "プロファイルを検索...",
|
||||
@@ -178,7 +197,11 @@
|
||||
"integrations": "統合",
|
||||
"importProfile": "プロファイルをインポート",
|
||||
"extensions": "拡張機能"
|
||||
}
|
||||
},
|
||||
"newProfile": "新規",
|
||||
"donutLogo": "Donut Browser ロゴ",
|
||||
"scrollGroupsLeft": "グループを左へスクロール",
|
||||
"scrollGroupsRight": "グループを右へスクロール"
|
||||
},
|
||||
"profiles": {
|
||||
"title": "プロファイル",
|
||||
@@ -196,7 +219,14 @@
|
||||
"group": "グループ",
|
||||
"proxy": "プロキシ / VPN",
|
||||
"lastLaunch": "最終起動",
|
||||
"empty": "プロファイルが見つかりません。"
|
||||
"empty": "プロファイルが見つかりません。",
|
||||
"notSelected": "未選択",
|
||||
"ext": "拡張",
|
||||
"dns": "DNS",
|
||||
"extDefault": "既定",
|
||||
"dnsLevel": "DNS ブロックリスト: {{level}}",
|
||||
"extSearch": "グループを検索…",
|
||||
"extEmpty": "拡張機能グループがありません"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "起動",
|
||||
@@ -211,7 +241,10 @@
|
||||
"assignToGroup": "グループに割り当て",
|
||||
"changeFingerprint": "フィンガープリントを変更",
|
||||
"copyCookiesToProfile": "Cookieをプロファイルにコピー",
|
||||
"launchHook": "起動フックURL"
|
||||
"launchHook": "起動フックURL",
|
||||
"setPassword": "パスワードを設定",
|
||||
"changePassword": "パスワードを変更",
|
||||
"removePassword": "パスワードを削除"
|
||||
},
|
||||
"synchronizer": {
|
||||
"launchWithSync": "シンクロナイザーで起動",
|
||||
@@ -230,9 +263,8 @@
|
||||
"flakyTooltip": "このプロフィールはリーダーと画面解像度が異なります。ページレイアウトが異なる可能性があり、クリックや操作が正しく動作しない場合があります。"
|
||||
},
|
||||
"ephemeral": "一時的",
|
||||
"ephemeralDescription": "ブラウザはプロファイルデータをディスクではなくメモリに書き込むよう強制されます。ブラウザを閉じるとデータは削除されます。",
|
||||
"ephemeralDescription": "ブラウザはプロファイルデータをディスクではなくメモリに書き込むよう強制されます。ただし、OSはメモリ不足時にメモリの一部をディスクにスワップすることがあるため、セッションの痕跡が復元可能な状態で残る場合があります。",
|
||||
"ephemeralBadge": "一時的",
|
||||
"ephemeralAlpha": "Alpha",
|
||||
"bulkDelete": {
|
||||
"title": "選択したプロファイルを削除",
|
||||
"description": "この操作は取り消せません。{{count}} 個のプロファイルと関連するすべてのデータが永久に削除されます。",
|
||||
@@ -255,6 +287,10 @@
|
||||
"assignProxy": "プロキシを割り当て",
|
||||
"assignExtensionGroup": "拡張機能グループを割り当て",
|
||||
"copyCookies": "Cookieをコピー"
|
||||
},
|
||||
"passwordProtectedBadge": "パスワード保護",
|
||||
"launchHook": {
|
||||
"placeholder": "https://example.com/track-launch"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -302,7 +338,11 @@
|
||||
"firefoxLabel": "Firefox",
|
||||
"firefoxSubtitle": "Camoufox搭載",
|
||||
"camoufoxWarning": "Firefox(Camoufox)はサードパーティの組織によって管理されています。本番環境での使用にはChromiumをご利用ください。",
|
||||
"platformUnavailable": "{{browser}} はまだお使いのプラットフォームで利用できません。"
|
||||
"platformUnavailable": "{{browser}} はまだお使いのプラットフォームで利用できません。",
|
||||
"passwordProtect": {
|
||||
"label": "このプロファイルをパスワードで保護",
|
||||
"description": "ディスク上のプロファイルデータを暗号化します。起動に必要です。"
|
||||
}
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "プロファイルを削除",
|
||||
@@ -488,7 +528,9 @@
|
||||
"deleteGroupAndProfiles": "グループとプロファイルを削除",
|
||||
"loadProfilesFailed": "プロファイルの読み込みに失敗しました",
|
||||
"unknownGroup": "不明なグループ",
|
||||
"profileGroupsAriaLabel": "プロファイルグループ"
|
||||
"profileGroupsAriaLabel": "プロファイルグループ",
|
||||
"loading": "グループを読み込み中...",
|
||||
"all": "すべて"
|
||||
},
|
||||
"sync": {
|
||||
"mode": {
|
||||
@@ -540,6 +582,7 @@
|
||||
"openLogin": "ログイン",
|
||||
"linkCodeLabel": "ログインコード",
|
||||
"linkCodePlaceholder": "ウェブサイトのコードを貼り付け",
|
||||
"signInTitle": "サインイン",
|
||||
"verifyAndLogin": "認証してログイン",
|
||||
"loggingIn": "ログイン中...",
|
||||
"connected": "接続済み",
|
||||
@@ -631,7 +674,8 @@
|
||||
"mcpAcceptTermsFirst": "(設定で先に Wayfern の規約に同意してください)",
|
||||
"mcpStarted": "MCP サーバーをポート {{port}} で起動しました",
|
||||
"mcpStopped": "MCP サーバーを停止しました",
|
||||
"mcpToggleFailed": "MCP サーバーの切り替えに失敗しました"
|
||||
"mcpToggleFailed": "MCP サーバーの切り替えに失敗しました",
|
||||
"openSettings": "統合設定を開く"
|
||||
},
|
||||
"import": {
|
||||
"title": "プロファイルをインポート",
|
||||
@@ -711,6 +755,10 @@
|
||||
"webrtc": "WebRTCをブロック",
|
||||
"webgl": "WebGLをブロック"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
"browserBehavior": "ブラウザの動作",
|
||||
"allowAddonsOpenTabs": "ブラウザアドオンが新しいタブを自動的に開くことを許可"
|
||||
}
|
||||
},
|
||||
"cookies": {
|
||||
@@ -875,7 +923,9 @@
|
||||
"loadProxiesFailed": "プロキシの読み込みに失敗しました: {{error}}",
|
||||
"setupProxyListenersFailed": "プロキシイベントリスナーの設定に失敗しました: {{error}}",
|
||||
"loadVpnConfigsFailed": "VPN設定の読み込みに失敗しました: {{error}}",
|
||||
"setupVpnListenersFailed": "VPNイベントリスナーの設定に失敗しました: {{error}}"
|
||||
"setupVpnListenersFailed": "VPNイベントリスナーの設定に失敗しました: {{error}}",
|
||||
"themeNotFound": "Tokyo Night テーマが見つかりません",
|
||||
"setProfilePasswordFailed": "プロファイルのパスワード設定に失敗しました: {{error}}"
|
||||
},
|
||||
"browser": {
|
||||
"camoufox": "Camoufox",
|
||||
@@ -901,7 +951,7 @@
|
||||
"blockWebRTC": "WebRTCをブロック",
|
||||
"blockWebGL": "WebGLをブロック",
|
||||
"navigatorProperties": "Navigatorプロパティ",
|
||||
"userAgent": "User Agent",
|
||||
"userAgent": "ユーザーエージェント",
|
||||
"userAgentAndPlatform": "User Agent & Platform",
|
||||
"platform": "Platform",
|
||||
"platformVersion": "Platform Version",
|
||||
@@ -909,7 +959,7 @@
|
||||
"osCpu": "OS CPU",
|
||||
"hardwareConcurrency": "Hardware Concurrency",
|
||||
"maxTouchPoints": "最大タッチポイント数",
|
||||
"doNotTrack": "Do Not Track",
|
||||
"doNotTrack": "追跡しない",
|
||||
"selectDntPlaceholder": "DNT値を選択",
|
||||
"dntAllowed": "0(トラッキング許可)",
|
||||
"dntNotAllowed": "1(トラッキング不許可)",
|
||||
@@ -931,8 +981,8 @@
|
||||
"outerHeight": "外側の高さ",
|
||||
"innerWidth": "内側の幅",
|
||||
"innerHeight": "内側の高さ",
|
||||
"screenX": "Screen X",
|
||||
"screenY": "Screen Y",
|
||||
"screenX": "画面 X",
|
||||
"screenY": "画面 Y",
|
||||
"geolocation": "ジオロケーション",
|
||||
"timezoneAndGeolocation": "タイムゾーンとジオロケーション",
|
||||
"timezoneGeolocationDescription": "これらの値はブラウザのタイムゾーンとジオロケーションAPIを上書きします。",
|
||||
@@ -946,15 +996,15 @@
|
||||
"region": "地域",
|
||||
"script": "スクリプト",
|
||||
"webglProperties": "WebGLプロパティ",
|
||||
"webglVendor": "WebGL Vendor",
|
||||
"webglRenderer": "WebGL Renderer",
|
||||
"webglVendor": "WebGL ベンダー",
|
||||
"webglRenderer": "WebGL レンダラー",
|
||||
"webglParameters": "WebGLパラメータ",
|
||||
"webglParametersJson": "WebGLパラメータ (JSON)",
|
||||
"webgl2Parameters": "WebGL2パラメータ",
|
||||
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
|
||||
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
|
||||
"webglShaderPrecisionFormats": "WebGL シェーダー精度フォーマット",
|
||||
"webgl2ShaderPrecisionFormats": "WebGL2 シェーダー精度フォーマット",
|
||||
"canvasFingerprint": "Canvas Fingerprint",
|
||||
"canvasNoiseSeed": "Canvas Noise Seed",
|
||||
"canvasNoiseSeed": "Canvas ノイズシード",
|
||||
"canvasNoiseSeedDescription": "このシードは一貫性がありながらもユニークなCanvasフィンガープリントを生成するために使用されます。各プロファイルには異なるシードを設定してください。",
|
||||
"fonts": "フォント",
|
||||
"fontsJson": "フォント (JSON配列)",
|
||||
@@ -975,8 +1025,8 @@
|
||||
"maxChannelCount": "最大チャンネル数",
|
||||
"vendorInfo": "ベンダー情報",
|
||||
"vendor": "ベンダー",
|
||||
"vendorSub": "Vendor Sub",
|
||||
"productSub": "Product Sub",
|
||||
"vendorSub": "ベンダーサブ",
|
||||
"productSub": "プロダクトサブ",
|
||||
"brand": "ブランド",
|
||||
"brandVersion": "ブランドバージョン",
|
||||
"proFeature": "これはPro機能です",
|
||||
@@ -1033,13 +1083,22 @@
|
||||
"lastLaunched": "最終起動",
|
||||
"hostOs": "ホストOS",
|
||||
"ephemeral": "エフェメラル",
|
||||
"extensionGroup": "拡張機能グループ"
|
||||
"extensionGroup": "拡張機能グループ",
|
||||
"totalSessions": "合計セッション",
|
||||
"syncMode": "同期モード",
|
||||
"proxy": "プロキシ",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "保存された Cookie",
|
||||
"localDataTransfer": "ローカルデータ転送量"
|
||||
},
|
||||
"values": {
|
||||
"none": "なし",
|
||||
"never": "なし",
|
||||
"copied": "コピーしました!",
|
||||
"yes": "はい"
|
||||
"yes": "はい",
|
||||
"activeNow": "現在アクティブ",
|
||||
"direct": "直接",
|
||||
"loading": "読み込み中…"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "プロキシバイパスルール",
|
||||
@@ -1053,8 +1112,9 @@
|
||||
"launchHook": {
|
||||
"title": "起動フックURL",
|
||||
"label": "起動フックURL",
|
||||
"description": "プロファイルが起動されるたびに、Donut BrowserはこのURLにPOSTリクエストを送信します。",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
"description": "プロファイルが起動するたびに、このURLにGETリクエストが送信されます。",
|
||||
"placeholder": "https://example.com/hooks/profile-launch",
|
||||
"invalidUrlHint": "有効な http:// または https:// URL を入力してください。"
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Cookieを管理",
|
||||
@@ -1065,6 +1125,48 @@
|
||||
"description": "複製されたプロフィールの名前を入力してください",
|
||||
"namePlaceholder": "プロフィール名",
|
||||
"button": "複製"
|
||||
},
|
||||
"duplicate": "複製",
|
||||
"breadcrumbRoot": "プロファイル",
|
||||
"openDialog": "設定を開く",
|
||||
"sections": {
|
||||
"overview": "概要",
|
||||
"fingerprint": "フィンガープリント",
|
||||
"network": "ネットワーク",
|
||||
"cookies": "Cookie",
|
||||
"extensions": "拡張機能",
|
||||
"sync": "同期",
|
||||
"automation": "自動化",
|
||||
"security": "セキュリティ",
|
||||
"delete": "プロファイルを削除",
|
||||
"activity": "アクティビティ",
|
||||
"launchHook": "起動フック"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "フィンガープリント対策スクリプトに対するこのプロファイルの表示を設定します。",
|
||||
"network": "このプロファイルが使用するプロキシまたは VPN を管理します。",
|
||||
"cookies": "このプロファイルの Cookie をインポート、コピー、消去します。",
|
||||
"extensions": "このプロファイル起動時に読み込む拡張機能を選択します。",
|
||||
"sync": "他のデバイス間でのこのプロファイルの同期方法を設定します。",
|
||||
"automation": "このプロファイル起動時に実行するコマンドを設定します。",
|
||||
"security": "プロファイルデータをパスワードで暗号化します。",
|
||||
"launchHook": "プロファイルが起動するたびに、このURLにGETリクエストを送信します。"
|
||||
},
|
||||
"badges": {
|
||||
"locked": "ロック中",
|
||||
"active": "有効"
|
||||
},
|
||||
"cookies": {
|
||||
"runningNotice": "ブラウザの実行中は Cookie を読み取れません。先にこのプロファイルを閉じてください。",
|
||||
"domainsHeader": "ドメイン ({{count}})"
|
||||
},
|
||||
"security": {
|
||||
"protected": "このプロファイルはパスワードで暗号化されています。",
|
||||
"unprotected": "このプロファイルは暗号化されていません。パスワードを設定して保護してください。",
|
||||
"cannotWhileRunning": "パスワードを変更する前にプロファイルを停止してください。"
|
||||
},
|
||||
"fingerprint": {
|
||||
"notSupported": "フィンガープリント編集は Camoufox / Wayfern プロファイルでのみ利用できます。"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1124,7 +1226,9 @@
|
||||
"syncEnabled": "同期が有効",
|
||||
"syncDisabled": "同期が無効",
|
||||
"syncEnableTooltip": "同期を有効にする",
|
||||
"syncDisableTooltip": "同期を無効にする"
|
||||
"syncDisableTooltip": "同期を無効にする",
|
||||
"loadGroupsFailed": "拡張機能グループの読み込みに失敗しました",
|
||||
"assignGroupFailed": "拡張機能グループの割り当てに失敗しました"
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
@@ -1256,12 +1360,11 @@
|
||||
"importedSuccess": "プロファイル「{{name}}」をインポートしました",
|
||||
"notInstalled": "{{browser}} はインストールされていません。メインウィンドウから {{browser}} をダウンロードしてからもう一度インポートしてください。",
|
||||
"importFailed": "プロファイルのインポートに失敗しました: {{error}}",
|
||||
"importedAsPrefix": "このプロファイルは次のプロファイルとしてインポートされます:",
|
||||
"importedAsSuffix": "",
|
||||
"proxyOptional": "プロキシ (任意)",
|
||||
"noProxy": "プロキシなし",
|
||||
"nextButton": "次へ",
|
||||
"importButton": "インポート"
|
||||
"importButton": "インポート",
|
||||
"importedAs": "このプロファイルは {{browser}} プロファイルとしてインポートされます。"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "同期中...",
|
||||
@@ -1424,7 +1527,11 @@
|
||||
"grantAccessButton": "アクセスを許可",
|
||||
"requestSuccessMicrophone": "マイクアクセスをリクエストしました",
|
||||
"requestSuccessCamera": "カメラアクセスをリクエストしました",
|
||||
"requestFailed": "許可のリクエストに失敗しました"
|
||||
"requestFailed": "許可のリクエストに失敗しました",
|
||||
"stillNotGrantedMicrophone": "マイクへのアクセスはまだ許可されていません。システム設定 → プライバシーとセキュリティ → マイク で手動で有効にする必要があるかもしれません。",
|
||||
"stillNotGrantedCamera": "カメラへのアクセスはまだ許可されていません。システム設定 → プライバシーとセキュリティ → カメラ で手動で有効にする必要があるかもしれません。",
|
||||
"grantedToastMicrophone": "マイクへのアクセスが許可されました",
|
||||
"grantedToastCamera": "カメラへのアクセスが許可されました"
|
||||
},
|
||||
"traffic": {
|
||||
"title": "トラフィックの詳細",
|
||||
@@ -1503,7 +1610,12 @@
|
||||
"syncTooltipNotSynced": "未同期",
|
||||
"noTags": "タグなし",
|
||||
"syncTooltipCloseToSync": "プロファイルを閉じて同期",
|
||||
"syncTooltipDisabledWithLast": "同期無効、最終同期 {{time}}"
|
||||
"syncTooltipDisabledWithLast": "同期無効、最終同期 {{time}}",
|
||||
"addTagsPlaceholder": "タグを追加",
|
||||
"tagsHeader": "タグ",
|
||||
"noteHeader": "メモ",
|
||||
"vpnsHeading": "VPN",
|
||||
"createByCountryHeading": "国別に作成"
|
||||
},
|
||||
"releaseTypeSelector": {
|
||||
"noReleaseTypes": "利用可能なリリースタイプがありません。",
|
||||
@@ -1521,7 +1633,14 @@
|
||||
"appUpdate": {
|
||||
"toast": {
|
||||
"updateFailed": "Donut Browser の更新に失敗しました",
|
||||
"restartFailed": "再起動に失敗しました"
|
||||
"restartFailed": "再起動に失敗しました",
|
||||
"updateReady": "アップデートの準備完了。再起動して適用",
|
||||
"manualDownloadRequired": "手動ダウンロードが必要です",
|
||||
"restartNow": "今すぐ再起動",
|
||||
"viewRelease": "リリースを見る",
|
||||
"later": "後で",
|
||||
"uploading": "アップロード中",
|
||||
"downloading": "ダウンロード中"
|
||||
}
|
||||
},
|
||||
"browserDownload": {
|
||||
@@ -1532,7 +1651,10 @@
|
||||
"downloadFailed": "{{browser}} {{version}} のダウンロードに失敗しました",
|
||||
"calculating": "計算中...",
|
||||
"extractionFailed": "{{browser}} {{version}}: 展開に失敗しました",
|
||||
"extractionFailedDescription": "破損したファイルは削除されました。次回の試行時に再ダウンロードされます。"
|
||||
"extractionFailedDescription": "破損したファイルは削除されました。次回の試行時に再ダウンロードされます。",
|
||||
"extracting": "ブラウザファイルを展開中... アプリを閉じないでください。",
|
||||
"verifying": "ブラウザファイルを検証中...",
|
||||
"downloadingRolling": "ローリングリリースビルドをダウンロード中..."
|
||||
}
|
||||
},
|
||||
"versionUpdater": {
|
||||
@@ -1552,5 +1674,156 @@
|
||||
"upToDateDescription": "すべてのブラウザバージョンは最新です",
|
||||
"updateAllFailed": "ブラウザバージョンの更新に失敗しました"
|
||||
}
|
||||
},
|
||||
"profilePassword": {
|
||||
"set": {
|
||||
"title": "プロファイルにパスワードを設定",
|
||||
"description": "{{name}} のディスク上のデータを暗号化します。プロファイルを起動するたびにこのパスワードが必要になります。",
|
||||
"button": "プロファイルを暗号化"
|
||||
},
|
||||
"unlock": {
|
||||
"title": "プロファイルを解除",
|
||||
"description": "{{name}} を解除するためのパスワードを入力してください。",
|
||||
"button": "解除"
|
||||
},
|
||||
"change": {
|
||||
"title": "プロファイルのパスワードを変更",
|
||||
"description": "新しいパスワードで {{name}} を再暗号化します。",
|
||||
"button": "パスワードを変更"
|
||||
},
|
||||
"remove": {
|
||||
"title": "プロファイルのパスワードを削除",
|
||||
"description": "{{name}} のディスク上のデータを復号します。プロファイルはパスワード保護されなくなります。",
|
||||
"button": "パスワードを削除"
|
||||
},
|
||||
"fields": {
|
||||
"password": "パスワード",
|
||||
"currentPassword": "現在のパスワード",
|
||||
"newPassword": "新しいパスワード",
|
||||
"confirm": "パスワードの確認",
|
||||
"confirmPassword": "新しいパスワード(確認)"
|
||||
},
|
||||
"errors": {
|
||||
"oldPasswordRequired": "現在のパスワードが必要です",
|
||||
"passwordRequired": "パスワードが必要です",
|
||||
"tooShort": "パスワードは 8 文字以上必要です",
|
||||
"mismatch": "パスワードが一致しません"
|
||||
},
|
||||
"toasts": {
|
||||
"set": "プロファイルがパスワードで保護されました",
|
||||
"changed": "プロファイルのパスワードを変更しました",
|
||||
"removed": "プロファイルのパスワードを削除しました"
|
||||
},
|
||||
"warnings": {
|
||||
"forgetWarningTitle": "重要: このパスワードは復元できません",
|
||||
"forgetWarningBody": "Donut Browserはこのパスワードをリセット、復元、回避することはできません。忘れた場合、このプロファイルのデータへのアクセスは永続的に失われます。"
|
||||
},
|
||||
"modes": {
|
||||
"set": "設定",
|
||||
"change": "変更",
|
||||
"remove": "削除"
|
||||
}
|
||||
},
|
||||
"backendErrors": {
|
||||
"incorrectPassword": "パスワードが正しくありません",
|
||||
"lockedOut": "失敗回数が多すぎます。{{duration}}後に再試行してください。",
|
||||
"lockedOutDuration": {
|
||||
"seconds": "{{seconds}}秒",
|
||||
"minutes": "{{minutes}}分",
|
||||
"hours": "{{hours}}時間"
|
||||
},
|
||||
"profileNotFound": "プロファイルが見つかりません",
|
||||
"profileNotProtected": "プロファイルはパスワード保護されていません",
|
||||
"profileAlreadyProtected": "プロファイルはすでにパスワード保護されています",
|
||||
"profileRunning": "プロファイルの実行中はこの操作を実行できません",
|
||||
"profileEphemeral": "エフェメラル プロファイルにはパスワードを設定できません — 終了時にデータが消去されます。",
|
||||
"profileMissingSalt": "プロファイルに暗号化ソルトがありません",
|
||||
"profileLocked": "プロファイルはロックされています。先にパスワードを入力してください。",
|
||||
"invalidProfileId": "無効なプロファイルIDです",
|
||||
"passwordTooShort": "パスワードは {{min}} 文字以上必要です",
|
||||
"internal": "問題が発生しました: {{detail}}",
|
||||
"invalidLaunchHookUrl": "起動フックURLが無効です。完全な http:// または https:// URL を使用してください。",
|
||||
"cookieDbLocked": "Cookie を読み取れません — データベースがロックされています。ブラウザを閉じてから再試行してください。",
|
||||
"cookieDbUnavailable": "Cookie を読み取れません — Cookie ストアを利用できません。",
|
||||
"selfHostedRequiresLogout": "セルフホストサーバーを設定する前に Donut アカウントからサインアウトしてください。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "プロファイル",
|
||||
"proxies": "プロキシ",
|
||||
"extensions": "拡張機能",
|
||||
"groups": "グループ",
|
||||
"settings": "設定",
|
||||
"more": {
|
||||
"label": "その他",
|
||||
"closeAriaLabel": "メニューを閉じる",
|
||||
"importProfile": "プロファイルをインポート",
|
||||
"importProfileHint": "別のツールから取り込む",
|
||||
"integrations": "連携",
|
||||
"integrationsHint": "Slack、MCP、自動化",
|
||||
"account": "アカウント",
|
||||
"accountHint": "クラウド、請求、サインイン"
|
||||
}
|
||||
},
|
||||
"pageTitle": {
|
||||
"proxies": "プロキシ",
|
||||
"extensions": "拡張機能",
|
||||
"groups": "グループ",
|
||||
"vpns": "VPN",
|
||||
"settings": "設定",
|
||||
"integrations": "連携",
|
||||
"account": "アカウント",
|
||||
"import": "プロファイルをインポート"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
"title": "同期一時停止 — パスワードが必要です",
|
||||
"description": "暗号化されたデータがダウンロードされましたが、このデバイスにはE2Eパスワードが設定されていません。設定 → 暗号化を開いてパスワードを入力し、同期を再開してください。",
|
||||
"openSettings": "設定を開く"
|
||||
},
|
||||
"rollover": {
|
||||
"startedTitle": "データを再暗号化しています",
|
||||
"startedDescription": "同期済みのすべての項目を新しいパスワードで再アップロードしています。最初にプロファイル、次にプロキシ、グループ、VPN、拡張機能の順です。",
|
||||
"progressTitle": "{{stage}}を再暗号化中",
|
||||
"progressDescription": "{{done}}/{{total}}",
|
||||
"completedTitle": "再暗号化完了",
|
||||
"completedDescription": "同期されたすべてのデータが新しいパスワードで封印されました。",
|
||||
"stage": {
|
||||
"profiles": "プロファイル",
|
||||
"proxies": "プロキシ",
|
||||
"groups": "グループ",
|
||||
"vpns": "VPN",
|
||||
"extensions": "拡張機能",
|
||||
"extension_groups": "拡張機能グループ"
|
||||
}
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"refreshed": "アカウントを更新しました",
|
||||
"loggedOut": "ログアウトしました",
|
||||
"signedOut": "未ログイン",
|
||||
"signedOutDescription": "クラウド同期、暗号化プロファイル、チーム機能を有効にするにはサインインしてください。",
|
||||
"plan": "プラン: {{plan}} · {{period}}",
|
||||
"refresh": "更新",
|
||||
"logout": "サインアウト",
|
||||
"signIn": "サインイン",
|
||||
"fields": {
|
||||
"plan": "プラン",
|
||||
"status": "ステータス",
|
||||
"teamRole": "チームロール",
|
||||
"period": "請求周期"
|
||||
},
|
||||
"tabs": {
|
||||
"account": "アカウント",
|
||||
"selfHosted": "セルフホスト"
|
||||
},
|
||||
"selfHosted": {
|
||||
"title": "セルフホスト同期サーバー",
|
||||
"description": "Donut を独自の donut-sync サーバーに接続して、ホスト型クラウドを使わずにプロファイル、プロキシ、グループ、拡張機能を同期します。",
|
||||
"disabledWhileLoggedIn": "Donut アカウントにサインインしている間はセルフホスト同期を利用できません。カスタムサーバーを使うにはサインアウトしてください。",
|
||||
"connectionStatus": "接続:",
|
||||
"statusUnknown": "未テスト",
|
||||
"testConnection": "接続をテスト",
|
||||
"disconnect": "切断"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+314
-41
@@ -30,7 +30,9 @@
|
||||
"saveSettings": "Salvar Configurações",
|
||||
"moreInfo": "Mais informações",
|
||||
"downloading": "Baixando...",
|
||||
"minimize": "Minimizar"
|
||||
"minimize": "Minimizar",
|
||||
"saving": "Salvando…",
|
||||
"saved": "Salvo"
|
||||
},
|
||||
"status": {
|
||||
"active": "Ativo",
|
||||
@@ -60,7 +62,8 @@
|
||||
"optional": "Opcional",
|
||||
"required": "Obrigatório",
|
||||
"unknownProfile": "Desconhecido",
|
||||
"mode": "Modo"
|
||||
"mode": "Modo",
|
||||
"never": "Nunca"
|
||||
},
|
||||
"time": {
|
||||
"days": "dias",
|
||||
@@ -72,7 +75,11 @@
|
||||
"aria": {
|
||||
"selectAll": "Selecionar tudo",
|
||||
"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": {
|
||||
"escape": "Esc"
|
||||
@@ -87,7 +94,11 @@
|
||||
"title": "Paleta de comandos",
|
||||
"description": "Pesquise um comando para executar..."
|
||||
},
|
||||
"noResults": "Nenhum resultado encontrado."
|
||||
"noResults": "Nenhum resultado encontrado.",
|
||||
"srOnly": {
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configurações",
|
||||
@@ -152,18 +163,26 @@
|
||||
"commercial": {
|
||||
"title": "Licença Comercial",
|
||||
"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",
|
||||
"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.",
|
||||
"subscriptionActive": "Assinado — plano {{plan}}",
|
||||
"subscriptionActiveDescription": "Sua assinatura do Donut Browser está ativa. O uso comercial está licenciado enquanto seu plano estiver vigente."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Avançado",
|
||||
"clearCache": "Limpar Todo o Cache de Versões",
|
||||
"clearCacheDescription": "Limpa todos os dados de versões de navegadores em cache e atualiza todas as versões de suas fontes. Isso forçará um novo download das informações de versão para todos os navegadores.",
|
||||
"clearCacheFailed": "Falha ao limpar o cache"
|
||||
"clearCacheFailed": "Falha ao limpar o cache",
|
||||
"copyLogs": "Copiar logs",
|
||||
"openLogDir": "Abrir pasta de logs",
|
||||
"copyLogsSuccess": "Logs copiados para a área de transferência",
|
||||
"copyLogsDescription": "Junta os arquivos de log mais recentes (até 5 MB) na sua área de transferência para compartilhar em relatórios de bug."
|
||||
},
|
||||
"disableAutoUpdates": "Desativar Atualizações Automáticas do App",
|
||||
"disableAutoUpdatesDescription": "Impede que o aplicativo verifique e instale atualizações do Donut Browser automaticamente. As atualizações de navegadores não são afetadas."
|
||||
"disableAutoUpdatesDescription": "Impede que o aplicativo verifique e instale atualizações do Donut Browser automaticamente. As atualizações de navegadores não são afetadas.",
|
||||
"keepDecryptedProfilesInRam": "Manter Perfis Descriptografados na RAM",
|
||||
"keepDecryptedProfilesInRamDescription": "Preserva a cópia descriptografada na RAM dos perfis protegidos por senha entre execuções para um início mais rápido. A cópia em disco permanece criptografada em qualquer caso."
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Pesquisar perfis...",
|
||||
@@ -178,7 +197,11 @@
|
||||
"integrations": "Integrações",
|
||||
"importProfile": "Importar Perfil",
|
||||
"extensions": "Extensões"
|
||||
}
|
||||
},
|
||||
"newProfile": "Novo",
|
||||
"donutLogo": "Logotipo do Donut Browser",
|
||||
"scrollGroupsLeft": "Rolar grupos para a esquerda",
|
||||
"scrollGroupsRight": "Rolar grupos para a direita"
|
||||
},
|
||||
"profiles": {
|
||||
"title": "Perfis",
|
||||
@@ -196,7 +219,14 @@
|
||||
"group": "Grupo",
|
||||
"proxy": "Proxy / VPN",
|
||||
"lastLaunch": "Último Início",
|
||||
"empty": "Nenhum perfil encontrado."
|
||||
"empty": "Nenhum perfil encontrado.",
|
||||
"notSelected": "Não selecionado",
|
||||
"ext": "EXT",
|
||||
"dns": "DNS",
|
||||
"extDefault": "Padrão",
|
||||
"dnsLevel": "Lista DNS: {{level}}",
|
||||
"extSearch": "Pesquisar grupos…",
|
||||
"extEmpty": "Sem grupos de extensões"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Iniciar",
|
||||
@@ -211,7 +241,10 @@
|
||||
"assignToGroup": "Atribuir ao Grupo",
|
||||
"changeFingerprint": "Alterar Impressão Digital",
|
||||
"copyCookiesToProfile": "Copiar Cookies para o Perfil",
|
||||
"launchHook": "URL do hook de inicialização"
|
||||
"launchHook": "URL do hook de inicialização",
|
||||
"setPassword": "Definir Senha",
|
||||
"changePassword": "Alterar Senha",
|
||||
"removePassword": "Remover Senha"
|
||||
},
|
||||
"synchronizer": {
|
||||
"launchWithSync": "Iniciar com Sincronizador",
|
||||
@@ -230,9 +263,8 @@
|
||||
"flakyTooltip": "Este perfil tem uma resolução de tela diferente do líder. O layout das páginas pode variar, fazendo com que cliques e interações atinjam elementos errados."
|
||||
},
|
||||
"ephemeral": "Efêmero",
|
||||
"ephemeralDescription": "O navegador é forçado a gravar os dados do perfil na memória em vez do disco. Os dados são excluídos ao fechar o navegador.",
|
||||
"ephemeralDescription": "O navegador é forçado a gravar os dados do perfil na memória em vez do disco. Lembre-se de que o sistema operacional pode passar partes da memória para o disco (swap) sob carga, então rastros da sessão ainda podem ser recuperáveis.",
|
||||
"ephemeralBadge": "Efêmero",
|
||||
"ephemeralAlpha": "Alpha",
|
||||
"bulkDelete": {
|
||||
"title": "Excluir perfis selecionados",
|
||||
"description": "Esta ação não pode ser desfeita. Excluirá permanentemente {{count}} perfil(is) e todos os dados associados.",
|
||||
@@ -255,6 +287,10 @@
|
||||
"assignProxy": "Atribuir proxy",
|
||||
"assignExtensionGroup": "Atribuir grupo de extensões",
|
||||
"copyCookies": "Copiar cookies"
|
||||
},
|
||||
"passwordProtectedBadge": "Protegido por Senha",
|
||||
"launchHook": {
|
||||
"placeholder": "https://example.com/track-launch"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -302,7 +338,11 @@
|
||||
"firefoxLabel": "Firefox",
|
||||
"firefoxSubtitle": "Desenvolvido com Camoufox",
|
||||
"camoufoxWarning": "O Firefox (Camoufox) é mantido por uma organização terceira. Para uso em produção, utilize o Chromium.",
|
||||
"platformUnavailable": "{{browser}} ainda não está disponível para sua plataforma."
|
||||
"platformUnavailable": "{{browser}} ainda não está disponível para sua plataforma.",
|
||||
"passwordProtect": {
|
||||
"label": "Proteger este perfil com senha",
|
||||
"description": "Criptografa os dados do perfil em disco. Necessário para iniciar."
|
||||
}
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Excluir Perfil",
|
||||
@@ -488,7 +528,9 @@
|
||||
"deleteGroupAndProfiles": "Excluir Grupo e Perfis",
|
||||
"loadProfilesFailed": "Falha ao carregar os perfis",
|
||||
"unknownGroup": "Grupo desconhecido",
|
||||
"profileGroupsAriaLabel": "Grupos de perfis"
|
||||
"profileGroupsAriaLabel": "Grupos de perfis",
|
||||
"loading": "Carregando grupos...",
|
||||
"all": "Todos"
|
||||
},
|
||||
"sync": {
|
||||
"mode": {
|
||||
@@ -540,6 +582,7 @@
|
||||
"openLogin": "Entrar",
|
||||
"linkCodeLabel": "Código de login",
|
||||
"linkCodePlaceholder": "Cole o código do site",
|
||||
"signInTitle": "Entrar",
|
||||
"verifyAndLogin": "Verificar e Entrar",
|
||||
"loggingIn": "Entrando...",
|
||||
"connected": "Conectado",
|
||||
@@ -631,7 +674,8 @@
|
||||
"mcpAcceptTermsFirst": "(Aceite primeiro os termos da Wayfern nas Configurações)",
|
||||
"mcpStarted": "Servidor MCP iniciado na porta {{port}}",
|
||||
"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": {
|
||||
"title": "Importar Perfil",
|
||||
@@ -711,6 +755,10 @@
|
||||
"webrtc": "Bloquear WebRTC",
|
||||
"webgl": "Bloquear WebGL"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
"browserBehavior": "Comportamento do navegador",
|
||||
"allowAddonsOpenTabs": "Permitir que extensões abram novas abas automaticamente"
|
||||
}
|
||||
},
|
||||
"cookies": {
|
||||
@@ -875,7 +923,9 @@
|
||||
"loadProxiesFailed": "Falha ao carregar os proxies: {{error}}",
|
||||
"setupProxyListenersFailed": "Falha ao configurar os listeners de eventos de proxies: {{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",
|
||||
"setProfilePasswordFailed": "Falha ao definir a senha do perfil: {{error}}"
|
||||
},
|
||||
"browser": {
|
||||
"camoufox": "Camoufox",
|
||||
@@ -901,15 +951,15 @@
|
||||
"blockWebRTC": "Bloquear WebRTC",
|
||||
"blockWebGL": "Bloquear WebGL",
|
||||
"navigatorProperties": "Propriedades do Navigator",
|
||||
"userAgent": "User Agent",
|
||||
"userAgent": "Agente do usuário",
|
||||
"userAgentAndPlatform": "User Agent & Platform",
|
||||
"platform": "Platform",
|
||||
"platformVersion": "Platform Version",
|
||||
"appVersion": "App Version",
|
||||
"osCpu": "OS CPU",
|
||||
"osCpu": "CPU do SO",
|
||||
"hardwareConcurrency": "Hardware Concurrency",
|
||||
"maxTouchPoints": "Pontos de Toque Máximos",
|
||||
"doNotTrack": "Do Not Track",
|
||||
"doNotTrack": "Não rastrear",
|
||||
"selectDntPlaceholder": "Selecionar valor DNT",
|
||||
"dntAllowed": "0 (rastreamento permitido)",
|
||||
"dntNotAllowed": "1 (rastreamento não permitido)",
|
||||
@@ -931,8 +981,8 @@
|
||||
"outerHeight": "Altura Externa",
|
||||
"innerWidth": "Largura Interna",
|
||||
"innerHeight": "Altura Interna",
|
||||
"screenX": "Screen X",
|
||||
"screenY": "Screen Y",
|
||||
"screenX": "Tela X",
|
||||
"screenY": "Tela Y",
|
||||
"geolocation": "Geolocalização",
|
||||
"timezoneAndGeolocation": "Fuso Horário e Geolocalização",
|
||||
"timezoneGeolocationDescription": "Estes valores substituem as APIs de fuso horário e geolocalização do navegador.",
|
||||
@@ -946,15 +996,15 @@
|
||||
"region": "Região",
|
||||
"script": "Script",
|
||||
"webglProperties": "Propriedades WebGL",
|
||||
"webglVendor": "WebGL Vendor",
|
||||
"webglRenderer": "WebGL Renderer",
|
||||
"webglVendor": "Fornecedor WebGL",
|
||||
"webglRenderer": "Renderizador WebGL",
|
||||
"webglParameters": "Parâmetros WebGL",
|
||||
"webglParametersJson": "Parâmetros WebGL (JSON)",
|
||||
"webgl2Parameters": "Parâmetros WebGL2",
|
||||
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
|
||||
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
|
||||
"webglShaderPrecisionFormats": "Formatos de precisão de shader WebGL",
|
||||
"webgl2ShaderPrecisionFormats": "Formatos de precisão de shader WebGL2",
|
||||
"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.",
|
||||
"fonts": "Fontes",
|
||||
"fontsJson": "Fontes (JSON array)",
|
||||
@@ -975,8 +1025,8 @@
|
||||
"maxChannelCount": "Contagem Máxima de Canais",
|
||||
"vendorInfo": "Informações do Fabricante",
|
||||
"vendor": "Fabricante",
|
||||
"vendorSub": "Vendor Sub",
|
||||
"productSub": "Product Sub",
|
||||
"vendorSub": "Fornecedor Sub",
|
||||
"productSub": "Produto Sub",
|
||||
"brand": "Marca",
|
||||
"brandVersion": "Versão da Marca",
|
||||
"proFeature": "Este é um recurso Pro",
|
||||
@@ -1033,13 +1083,22 @@
|
||||
"lastLaunched": "Último Lançamento",
|
||||
"hostOs": "SO Host",
|
||||
"ephemeral": "Efêmero",
|
||||
"extensionGroup": "Grupo de Extensões"
|
||||
"extensionGroup": "Grupo de Extensões",
|
||||
"totalSessions": "Sessões totais",
|
||||
"syncMode": "Modo de sinc.",
|
||||
"proxy": "PROXY",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Cookies armazenados",
|
||||
"localDataTransfer": "Transferência de dados local"
|
||||
},
|
||||
"values": {
|
||||
"none": "Nenhum",
|
||||
"never": "Nunca",
|
||||
"copied": "Copiado!",
|
||||
"yes": "Sim"
|
||||
"yes": "Sim",
|
||||
"activeNow": "Ativo agora",
|
||||
"direct": "Direto",
|
||||
"loading": "Carregando…"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Regras de Bypass de Proxy",
|
||||
@@ -1053,8 +1112,9 @@
|
||||
"launchHook": {
|
||||
"title": "URL do hook de inicialização",
|
||||
"label": "URL do hook de inicialização",
|
||||
"description": "O Donut Browser enviará uma requisição POST para esta URL sempre que o perfil for iniciado.",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
"description": "O Donut Browser enviará uma requisição GET para esta URL toda vez que o perfil for iniciado.",
|
||||
"placeholder": "https://example.com/hooks/profile-launch",
|
||||
"invalidUrlHint": "Insira uma URL válida http:// ou https://."
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Gerenciar Cookies",
|
||||
@@ -1065,6 +1125,48 @@
|
||||
"description": "Digite um nome para o perfil clonado",
|
||||
"namePlaceholder": "Nome do perfil",
|
||||
"button": "Clonar"
|
||||
},
|
||||
"duplicate": "Duplicar",
|
||||
"breadcrumbRoot": "Perfil",
|
||||
"openDialog": "Abrir configurações",
|
||||
"sections": {
|
||||
"overview": "Visão geral",
|
||||
"fingerprint": "Impressão digital",
|
||||
"network": "Rede",
|
||||
"cookies": "Cookies",
|
||||
"extensions": "Extensões",
|
||||
"sync": "Sincronização",
|
||||
"automation": "Automação",
|
||||
"security": "Segurança",
|
||||
"delete": "Excluir perfil",
|
||||
"activity": "Atividade",
|
||||
"launchHook": "Hook de inicialização"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Configure como este perfil aparece para scripts de fingerprinting.",
|
||||
"network": "Gerencie o proxy ou VPN usado por este perfil.",
|
||||
"cookies": "Importe, copie ou apague cookies deste perfil.",
|
||||
"extensions": "Escolha quais extensões carregar com este perfil.",
|
||||
"sync": "Configure como este perfil é espelhado entre seus dispositivos.",
|
||||
"automation": "Execute um comando ao iniciar este perfil.",
|
||||
"security": "Criptografe os dados do perfil com uma senha.",
|
||||
"launchHook": "Envia uma requisição GET para esta URL toda vez que o perfil é iniciado."
|
||||
},
|
||||
"badges": {
|
||||
"locked": "BLOQUEADO",
|
||||
"active": "ATIVO"
|
||||
},
|
||||
"cookies": {
|
||||
"runningNotice": "Não é possível ler cookies enquanto o navegador está em execução. Feche este perfil primeiro.",
|
||||
"domainsHeader": "Domínios ({{count}})"
|
||||
},
|
||||
"security": {
|
||||
"protected": "Este perfil está criptografado com senha.",
|
||||
"unprotected": "Este perfil não está criptografado. Defina uma senha para criptografá-lo.",
|
||||
"cannotWhileRunning": "Pare o perfil antes de alterar a senha."
|
||||
},
|
||||
"fingerprint": {
|
||||
"notSupported": "A edição de impressão digital só está disponível para perfis Camoufox e Wayfern."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1124,7 +1226,9 @@
|
||||
"syncEnabled": "Sincronização ativada",
|
||||
"syncDisabled": "Sincronização desativada",
|
||||
"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": {
|
||||
"badge": "PRO",
|
||||
@@ -1256,12 +1360,11 @@
|
||||
"importedSuccess": "Perfil \"{{name}}\" importado com sucesso",
|
||||
"notInstalled": "{{browser}} não está instalado. Baixe {{browser}} primeiro pela janela principal e tente importar novamente.",
|
||||
"importFailed": "Falha ao importar perfil: {{error}}",
|
||||
"importedAsPrefix": "Este perfil será importado como um perfil",
|
||||
"importedAsSuffix": ".",
|
||||
"proxyOptional": "Proxy (Opcional)",
|
||||
"noProxy": "Sem proxy",
|
||||
"nextButton": "Próximo",
|
||||
"importButton": "Importar"
|
||||
"importButton": "Importar",
|
||||
"importedAs": "Este perfil será importado como um perfil {{browser}}."
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Sincronizando...",
|
||||
@@ -1424,7 +1527,11 @@
|
||||
"grantAccessButton": "Conceder acesso",
|
||||
"requestSuccessMicrophone": "Acesso ao microfone solicitado",
|
||||
"requestSuccessCamera": "Acesso à câmera solicitado",
|
||||
"requestFailed": "Falha ao solicitar permissão"
|
||||
"requestFailed": "Falha ao solicitar permissão",
|
||||
"stillNotGrantedMicrophone": "O acesso ao microfone ainda não foi concedido. Pode ser necessário ativá-lo manualmente em Ajustes do Sistema → Privacidade e Segurança → Microfone.",
|
||||
"stillNotGrantedCamera": "O acesso à câmera ainda não foi concedido. Pode ser necessário ativá-lo manualmente em Ajustes do Sistema → Privacidade e Segurança → Câmera.",
|
||||
"grantedToastMicrophone": "Acesso ao microfone concedido",
|
||||
"grantedToastCamera": "Acesso à câmera concedido"
|
||||
},
|
||||
"traffic": {
|
||||
"title": "Detalhes do tráfego",
|
||||
@@ -1503,7 +1610,12 @@
|
||||
"syncTooltipNotSynced": "Não sincronizado",
|
||||
"noTags": "Sem tags",
|
||||
"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": {
|
||||
"noReleaseTypes": "Nenhum tipo de versão disponível.",
|
||||
@@ -1521,7 +1633,14 @@
|
||||
"appUpdate": {
|
||||
"toast": {
|
||||
"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": {
|
||||
@@ -1532,7 +1651,10 @@
|
||||
"downloadFailed": "Falha ao baixar {{browser}} {{version}}",
|
||||
"calculating": "calculando...",
|
||||
"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": {
|
||||
@@ -1552,5 +1674,156 @@
|
||||
"upToDateDescription": "Todas as versões dos navegadores estão atualizadas",
|
||||
"updateAllFailed": "Falha ao atualizar as versões dos navegadores"
|
||||
}
|
||||
},
|
||||
"profilePassword": {
|
||||
"set": {
|
||||
"title": "Definir Senha do Perfil",
|
||||
"description": "Criptografa os dados em disco de {{name}}. Você precisará desta senha sempre que abrir o perfil.",
|
||||
"button": "Criptografar Perfil"
|
||||
},
|
||||
"unlock": {
|
||||
"title": "Desbloquear Perfil",
|
||||
"description": "Digite a senha para desbloquear {{name}}.",
|
||||
"button": "Desbloquear"
|
||||
},
|
||||
"change": {
|
||||
"title": "Alterar Senha do Perfil",
|
||||
"description": "Recriptografe {{name}} com uma nova senha.",
|
||||
"button": "Alterar Senha"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Remover Senha do Perfil",
|
||||
"description": "Descriptografa os dados em disco de {{name}}. O perfil deixará de estar protegido por senha.",
|
||||
"button": "Remover Senha"
|
||||
},
|
||||
"fields": {
|
||||
"password": "Senha",
|
||||
"currentPassword": "Senha atual",
|
||||
"newPassword": "Nova senha",
|
||||
"confirm": "Confirmar senha",
|
||||
"confirmPassword": "Confirmar nova senha"
|
||||
},
|
||||
"errors": {
|
||||
"oldPasswordRequired": "A senha atual é obrigatória",
|
||||
"passwordRequired": "A senha é obrigatória",
|
||||
"tooShort": "A senha deve ter pelo menos 8 caracteres",
|
||||
"mismatch": "As senhas não coincidem"
|
||||
},
|
||||
"toasts": {
|
||||
"set": "O perfil agora está protegido por senha",
|
||||
"changed": "Senha do perfil alterada",
|
||||
"removed": "Senha do perfil removida"
|
||||
},
|
||||
"warnings": {
|
||||
"forgetWarningTitle": "Importante: esta senha não pode ser recuperada",
|
||||
"forgetWarningBody": "O Donut Browser não pode redefinir, recuperar ou contornar esta senha. Se você esquecê-la, perderá permanentemente o acesso aos dados deste perfil."
|
||||
},
|
||||
"modes": {
|
||||
"set": "Definir",
|
||||
"change": "Alterar",
|
||||
"remove": "Remover"
|
||||
}
|
||||
},
|
||||
"backendErrors": {
|
||||
"incorrectPassword": "Senha incorreta",
|
||||
"lockedOut": "Tentativas incorretas demais. Tente novamente em {{duration}}.",
|
||||
"lockedOutDuration": {
|
||||
"seconds": "{{seconds}}s",
|
||||
"minutes": "{{minutes}} min",
|
||||
"hours": "{{hours}} h"
|
||||
},
|
||||
"profileNotFound": "Perfil não encontrado",
|
||||
"profileNotProtected": "O perfil não está protegido por senha",
|
||||
"profileAlreadyProtected": "O perfil já está protegido por senha",
|
||||
"profileRunning": "Não é possível realizar esta ação enquanto o perfil está em execução",
|
||||
"profileEphemeral": "Perfis efêmeros não podem ser protegidos por senha — seus dados são apagados ao sair.",
|
||||
"profileMissingSalt": "O perfil está sem o sal de criptografia",
|
||||
"profileLocked": "O perfil está bloqueado. Digite a senha primeiro.",
|
||||
"invalidProfileId": "ID de perfil inválido",
|
||||
"passwordTooShort": "A senha deve ter pelo menos {{min}} caracteres",
|
||||
"internal": "Algo deu errado: {{detail}}",
|
||||
"invalidLaunchHookUrl": "URL do hook de inicialização inválida. Use uma URL completa http:// ou https://.",
|
||||
"cookieDbLocked": "Não foi possível ler os cookies — o banco de dados está bloqueado. Feche o navegador e tente novamente.",
|
||||
"cookieDbUnavailable": "Não foi possível ler os cookies — o repositório de cookies está indisponível.",
|
||||
"selfHostedRequiresLogout": "Saia da sua conta Donut antes de configurar um servidor auto-hospedado."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfis",
|
||||
"proxies": "Proxies",
|
||||
"extensions": "Extensões",
|
||||
"groups": "Grupos",
|
||||
"settings": "Configurações",
|
||||
"more": {
|
||||
"label": "Mais",
|
||||
"closeAriaLabel": "Fechar menu",
|
||||
"importProfile": "Importar perfil",
|
||||
"importProfileHint": "Trazer perfis de outra ferramenta",
|
||||
"integrations": "Integrações",
|
||||
"integrationsHint": "Slack, MCP, automações",
|
||||
"account": "Conta",
|
||||
"accountHint": "Nuvem, cobrança, login"
|
||||
}
|
||||
},
|
||||
"pageTitle": {
|
||||
"proxies": "Proxies",
|
||||
"extensions": "Extensões",
|
||||
"groups": "Grupos",
|
||||
"vpns": "VPN",
|
||||
"settings": "Configurações",
|
||||
"integrations": "Integrações",
|
||||
"account": "Conta",
|
||||
"import": "Importar perfil"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
"title": "Sincronização pausada — senha necessária",
|
||||
"description": "Dados criptografados foram baixados, mas nenhuma senha E2E está configurada neste dispositivo. Abra Configurações → Criptografia e insira a senha para retomar a sincronização.",
|
||||
"openSettings": "Abrir configurações"
|
||||
},
|
||||
"rollover": {
|
||||
"startedTitle": "Recriptografando seus dados",
|
||||
"startedDescription": "Estamos reenviando cada item sincronizado com a nova senha. Primeiro os perfis, depois proxies, grupos, VPNs e extensões.",
|
||||
"progressTitle": "Recriptografando {{stage}}",
|
||||
"progressDescription": "{{done}} de {{total}}",
|
||||
"completedTitle": "Recriptografia concluída",
|
||||
"completedDescription": "Todos os dados sincronizados estão selados com a nova senha.",
|
||||
"stage": {
|
||||
"profiles": "perfis",
|
||||
"proxies": "proxies",
|
||||
"groups": "grupos",
|
||||
"vpns": "VPNs",
|
||||
"extensions": "extensões",
|
||||
"extension_groups": "grupos de extensões"
|
||||
}
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"refreshed": "Conta atualizada",
|
||||
"loggedOut": "Sessão encerrada",
|
||||
"signedOut": "Sem sessão",
|
||||
"signedOutDescription": "Entre para ativar sincronização na nuvem, perfis criptografados e recursos de equipe.",
|
||||
"plan": "Plano: {{plan}} · {{period}}",
|
||||
"refresh": "Atualizar",
|
||||
"logout": "Sair",
|
||||
"signIn": "Entrar",
|
||||
"fields": {
|
||||
"plan": "Plano",
|
||||
"status": "Status",
|
||||
"teamRole": "Função na equipe",
|
||||
"period": "Período"
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Conta",
|
||||
"selfHosted": "Auto-hospedado"
|
||||
},
|
||||
"selfHosted": {
|
||||
"title": "Servidor de sincronização auto-hospedado",
|
||||
"description": "Conecte o Donut ao seu próprio servidor donut-sync para sincronizar perfis, proxies, grupos e extensões sem usar a nuvem hospedada.",
|
||||
"disabledWhileLoggedIn": "A sincronização auto-hospedada não está disponível enquanto você está conectado à sua conta Donut. Saia para usar um servidor personalizado.",
|
||||
"connectionStatus": "Conexão:",
|
||||
"statusUnknown": "Não testado",
|
||||
"testConnection": "Testar conexão",
|
||||
"disconnect": "Desconectar"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+313
-40
@@ -30,7 +30,9 @@
|
||||
"saveSettings": "Сохранить настройки",
|
||||
"moreInfo": "Подробнее",
|
||||
"downloading": "Загрузка...",
|
||||
"minimize": "Свернуть"
|
||||
"minimize": "Свернуть",
|
||||
"saving": "Сохраняем…",
|
||||
"saved": "Сохранено"
|
||||
},
|
||||
"status": {
|
||||
"active": "Активен",
|
||||
@@ -60,7 +62,8 @@
|
||||
"optional": "Необязательно",
|
||||
"required": "Обязательно",
|
||||
"unknownProfile": "Неизвестный",
|
||||
"mode": "Режим"
|
||||
"mode": "Режим",
|
||||
"never": "Никогда"
|
||||
},
|
||||
"time": {
|
||||
"days": "дней",
|
||||
@@ -72,7 +75,11 @@
|
||||
"aria": {
|
||||
"selectAll": "Выбрать все",
|
||||
"selectRow": "Выбрать строку",
|
||||
"selectProfile": "Выбрать профиль"
|
||||
"selectProfile": "Выбрать профиль",
|
||||
"copy": "Скопировать в буфер обмена",
|
||||
"copied": "Скопировано",
|
||||
"showToken": "Показать токен",
|
||||
"hideToken": "Скрыть токен"
|
||||
},
|
||||
"keys": {
|
||||
"escape": "Esc"
|
||||
@@ -87,7 +94,11 @@
|
||||
"title": "Палитра команд",
|
||||
"description": "Найдите команду для выполнения..."
|
||||
},
|
||||
"noResults": "Результаты не найдены."
|
||||
"noResults": "Результаты не найдены.",
|
||||
"srOnly": {
|
||||
"copy": "Скопировать",
|
||||
"copied": "Скопировано"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Настройки",
|
||||
@@ -152,18 +163,26 @@
|
||||
"commercial": {
|
||||
"title": "Коммерческая лицензия",
|
||||
"trialActive": "Пробный период: осталось {{days}} дней, {{hours}} часов",
|
||||
"trialActiveDescription": "Коммерческое использование бесплатно в течение пробного периода",
|
||||
"trialActiveDescription": "Коммерческое использование бесплатно в течение пробного периода. После его окончания все функции продолжают работать — личное использование остаётся бесплатным, и только для коммерческого использования потребуется лицензия.",
|
||||
"trialExpired": "Пробный период истёк",
|
||||
"trialExpiredDescription": "Личное использование остаётся бесплатным. Для коммерческого использования требуется лицензия."
|
||||
"trialExpiredDescription": "Личное использование остаётся бесплатным. Для коммерческого использования требуется лицензия.",
|
||||
"subscriptionActive": "Подписка активна — план {{plan}}",
|
||||
"subscriptionActiveDescription": "Ваша подписка на Donut Browser активна. Коммерческое использование лицензировано на срок действия плана."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Дополнительно",
|
||||
"clearCache": "Очистить весь кэш версий",
|
||||
"clearCacheDescription": "Очищает все кэшированные данные версий браузеров и обновляет все версии из источников. Это принудительно загрузит информацию о версиях для всех браузеров.",
|
||||
"clearCacheFailed": "Не удалось очистить кэш"
|
||||
"clearCacheFailed": "Не удалось очистить кэш",
|
||||
"copyLogs": "Скопировать логи",
|
||||
"openLogDir": "Открыть папку логов",
|
||||
"copyLogsSuccess": "Логи скопированы в буфер обмена",
|
||||
"copyLogsDescription": "Собирает последние файлы логов (до 5 МБ) в буфер обмена для прикрепления к багам."
|
||||
},
|
||||
"disableAutoUpdates": "Отключить автообновление приложения",
|
||||
"disableAutoUpdatesDescription": "Запретить автоматическую проверку и установку обновлений Donut Browser. Обновления браузеров не затрагиваются."
|
||||
"disableAutoUpdatesDescription": "Запретить автоматическую проверку и установку обновлений Donut Browser. Обновления браузеров не затрагиваются.",
|
||||
"keepDecryptedProfilesInRam": "Хранить расшифрованные профили в ОЗУ",
|
||||
"keepDecryptedProfilesInRamDescription": "Сохранять расшифрованную копию защищённых паролем профилей в ОЗУ между запусками для ускорения старта. Копия на диске в любом случае остаётся зашифрованной."
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Поиск профилей...",
|
||||
@@ -178,7 +197,11 @@
|
||||
"integrations": "Интеграции",
|
||||
"importProfile": "Импорт профиля",
|
||||
"extensions": "Расширения"
|
||||
}
|
||||
},
|
||||
"newProfile": "Новый",
|
||||
"donutLogo": "Логотип Donut Browser",
|
||||
"scrollGroupsLeft": "Прокрутить группы влево",
|
||||
"scrollGroupsRight": "Прокрутить группы вправо"
|
||||
},
|
||||
"profiles": {
|
||||
"title": "Профили",
|
||||
@@ -196,7 +219,14 @@
|
||||
"group": "Группа",
|
||||
"proxy": "Прокси / VPN",
|
||||
"lastLaunch": "Последний запуск",
|
||||
"empty": "Профили не найдены."
|
||||
"empty": "Профили не найдены.",
|
||||
"notSelected": "Не выбрано",
|
||||
"ext": "РАСШ",
|
||||
"dns": "DNS",
|
||||
"extDefault": "По умолч.",
|
||||
"dnsLevel": "DNS-блок-лист: {{level}}",
|
||||
"extSearch": "Поиск групп…",
|
||||
"extEmpty": "Нет групп расширений"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Запустить",
|
||||
@@ -211,7 +241,10 @@
|
||||
"assignToGroup": "Назначить группе",
|
||||
"changeFingerprint": "Изменить отпечаток",
|
||||
"copyCookiesToProfile": "Копировать Cookie в профиль",
|
||||
"launchHook": "URL хука запуска"
|
||||
"launchHook": "URL хука запуска",
|
||||
"setPassword": "Установить пароль",
|
||||
"changePassword": "Изменить пароль",
|
||||
"removePassword": "Удалить пароль"
|
||||
},
|
||||
"synchronizer": {
|
||||
"launchWithSync": "Запустить с синхронизатором",
|
||||
@@ -230,9 +263,8 @@
|
||||
"flakyTooltip": "У этого профиля разрешение экрана отличается от лидера. Макет страниц может отличаться, что может привести к неправильным кликам и взаимодействиям."
|
||||
},
|
||||
"ephemeral": "Временный",
|
||||
"ephemeralDescription": "Браузер принудительно записывает данные профиля в память вместо диска. Данные удаляются при закрытии браузера.",
|
||||
"ephemeralDescription": "Браузер вынужденно записывает данные профиля в память, а не на диск. Учтите, что операционная система может выгружать части памяти на диск (swap) при нехватке ОЗУ, поэтому следы сессии всё же могут оказаться восстановимыми.",
|
||||
"ephemeralBadge": "Временный",
|
||||
"ephemeralAlpha": "Alpha",
|
||||
"bulkDelete": {
|
||||
"title": "Удалить выбранные профили",
|
||||
"description": "Это действие нельзя отменить. Будет навсегда удалено {{count}} профил(ей) и все связанные данные.",
|
||||
@@ -255,6 +287,10 @@
|
||||
"assignProxy": "Назначить прокси",
|
||||
"assignExtensionGroup": "Назначить группу расширений",
|
||||
"copyCookies": "Копировать cookies"
|
||||
},
|
||||
"passwordProtectedBadge": "Защищено паролем",
|
||||
"launchHook": {
|
||||
"placeholder": "https://example.com/track-launch"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -302,7 +338,11 @@
|
||||
"firefoxLabel": "Firefox",
|
||||
"firefoxSubtitle": "На базе Camoufox",
|
||||
"camoufoxWarning": "Firefox (Camoufox) поддерживается сторонней организацией. Для промышленного использования используйте Chromium.",
|
||||
"platformUnavailable": "{{browser}} пока недоступен на вашей платформе."
|
||||
"platformUnavailable": "{{browser}} пока недоступен на вашей платформе.",
|
||||
"passwordProtect": {
|
||||
"label": "Защитить этот профиль паролем",
|
||||
"description": "Шифрует данные профиля на диске. Требуется для запуска."
|
||||
}
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Удалить профиль",
|
||||
@@ -488,7 +528,9 @@
|
||||
"deleteGroupAndProfiles": "Удалить группу и профили",
|
||||
"loadProfilesFailed": "Не удалось загрузить профили",
|
||||
"unknownGroup": "Неизвестная группа",
|
||||
"profileGroupsAriaLabel": "Группы профилей"
|
||||
"profileGroupsAriaLabel": "Группы профилей",
|
||||
"loading": "Загрузка групп...",
|
||||
"all": "Все"
|
||||
},
|
||||
"sync": {
|
||||
"mode": {
|
||||
@@ -540,6 +582,7 @@
|
||||
"openLogin": "Войти",
|
||||
"linkCodeLabel": "Код входа",
|
||||
"linkCodePlaceholder": "Вставьте код с сайта",
|
||||
"signInTitle": "Войти",
|
||||
"verifyAndLogin": "Подтвердить и Войти",
|
||||
"loggingIn": "Вход...",
|
||||
"connected": "Подключено",
|
||||
@@ -631,7 +674,8 @@
|
||||
"mcpAcceptTermsFirst": "(Сначала примите условия Wayfern в Настройках)",
|
||||
"mcpStarted": "MCP сервер запущен на порту {{port}}",
|
||||
"mcpStopped": "MCP сервер остановлен",
|
||||
"mcpToggleFailed": "Не удалось переключить MCP сервер"
|
||||
"mcpToggleFailed": "Не удалось переключить MCP сервер",
|
||||
"openSettings": "Открыть настройки интеграций"
|
||||
},
|
||||
"import": {
|
||||
"title": "Импорт профиля",
|
||||
@@ -711,6 +755,10 @@
|
||||
"webrtc": "Блокировать WebRTC",
|
||||
"webgl": "Блокировать WebGL"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
"browserBehavior": "Поведение браузера",
|
||||
"allowAddonsOpenTabs": "Разрешить расширениям браузера автоматически открывать новые вкладки"
|
||||
}
|
||||
},
|
||||
"cookies": {
|
||||
@@ -875,7 +923,9 @@
|
||||
"loadProxiesFailed": "Не удалось загрузить прокси: {{error}}",
|
||||
"setupProxyListenersFailed": "Не удалось настроить слушатели событий прокси: {{error}}",
|
||||
"loadVpnConfigsFailed": "Не удалось загрузить конфигурации VPN: {{error}}",
|
||||
"setupVpnListenersFailed": "Не удалось настроить слушатели событий VPN: {{error}}"
|
||||
"setupVpnListenersFailed": "Не удалось настроить слушатели событий VPN: {{error}}",
|
||||
"themeNotFound": "Тема Tokyo Night не найдена",
|
||||
"setProfilePasswordFailed": "Не удалось установить пароль профиля: {{error}}"
|
||||
},
|
||||
"browser": {
|
||||
"camoufox": "Camoufox",
|
||||
@@ -906,10 +956,10 @@
|
||||
"platform": "Платформа",
|
||||
"platformVersion": "Версия платформы",
|
||||
"appVersion": "Версия приложения",
|
||||
"osCpu": "OS CPU",
|
||||
"osCpu": "ЦП ОС",
|
||||
"hardwareConcurrency": "Количество потоков процессора",
|
||||
"maxTouchPoints": "Максимальное количество точек касания",
|
||||
"doNotTrack": "Do Not Track",
|
||||
"doNotTrack": "Не отслеживать",
|
||||
"selectDntPlaceholder": "Выберите значение DNT",
|
||||
"dntAllowed": "0 (отслеживание разрешено)",
|
||||
"dntNotAllowed": "1 (отслеживание не разрешено)",
|
||||
@@ -931,8 +981,8 @@
|
||||
"outerHeight": "Внешняя высота",
|
||||
"innerWidth": "Внутренняя ширина",
|
||||
"innerHeight": "Внутренняя высота",
|
||||
"screenX": "Screen X",
|
||||
"screenY": "Screen Y",
|
||||
"screenX": "Экран X",
|
||||
"screenY": "Экран Y",
|
||||
"geolocation": "Геолокация",
|
||||
"timezoneAndGeolocation": "Часовой пояс и геолокация",
|
||||
"timezoneGeolocationDescription": "Эти значения переопределяют API часового пояса и геолокации браузера.",
|
||||
@@ -946,15 +996,15 @@
|
||||
"region": "Регион",
|
||||
"script": "Скрипт",
|
||||
"webglProperties": "Свойства WebGL",
|
||||
"webglVendor": "WebGL Vendor",
|
||||
"webglRenderer": "WebGL Renderer",
|
||||
"webglVendor": "Производитель WebGL",
|
||||
"webglRenderer": "Рендерер WebGL",
|
||||
"webglParameters": "Параметры WebGL",
|
||||
"webglParametersJson": "Параметры WebGL (JSON)",
|
||||
"webgl2Parameters": "Параметры WebGL2",
|
||||
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
|
||||
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
|
||||
"webglShaderPrecisionFormats": "Форматы точности шейдера WebGL",
|
||||
"webgl2ShaderPrecisionFormats": "Форматы точности шейдера WebGL2",
|
||||
"canvasFingerprint": "Отпечаток Canvas",
|
||||
"canvasNoiseSeed": "Canvas Noise Seed",
|
||||
"canvasNoiseSeed": "Сид шума Canvas",
|
||||
"canvasNoiseSeedDescription": "Это зерно используется для генерации постоянного, но уникального отпечатка Canvas. У каждого профиля должно быть своё зерно.",
|
||||
"fonts": "Шрифты",
|
||||
"fontsJson": "Шрифты (JSON-массив)",
|
||||
@@ -975,8 +1025,8 @@
|
||||
"maxChannelCount": "Максимальное количество каналов",
|
||||
"vendorInfo": "Информация о производителе",
|
||||
"vendor": "Производитель",
|
||||
"vendorSub": "Vendor Sub",
|
||||
"productSub": "Product Sub",
|
||||
"vendorSub": "Подверсия производителя",
|
||||
"productSub": "Подверсия продукта",
|
||||
"brand": "Бренд",
|
||||
"brandVersion": "Версия бренда",
|
||||
"proFeature": "Это функция Pro",
|
||||
@@ -1033,13 +1083,22 @@
|
||||
"lastLaunched": "Последний запуск",
|
||||
"hostOs": "ОС хоста",
|
||||
"ephemeral": "Эфемерный",
|
||||
"extensionGroup": "Группа расширений"
|
||||
"extensionGroup": "Группа расширений",
|
||||
"totalSessions": "Всего сессий",
|
||||
"syncMode": "Режим синх.",
|
||||
"proxy": "ПРОКСИ",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Хранится Cookie",
|
||||
"localDataTransfer": "Локальный трафик"
|
||||
},
|
||||
"values": {
|
||||
"none": "Нет",
|
||||
"never": "Никогда",
|
||||
"copied": "Скопировано!",
|
||||
"yes": "Да"
|
||||
"yes": "Да",
|
||||
"activeNow": "Сейчас активен",
|
||||
"direct": "Без прокси",
|
||||
"loading": "Загрузка…"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Правила обхода прокси",
|
||||
@@ -1053,8 +1112,9 @@
|
||||
"launchHook": {
|
||||
"title": "URL хука запуска",
|
||||
"label": "URL хука запуска",
|
||||
"description": "Donut Browser будет отправлять POST-запрос на этот URL при каждом запуске профиля.",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
"description": "Donut Browser будет отправлять GET-запрос на этот URL при каждом запуске профиля.",
|
||||
"placeholder": "https://example.com/hooks/profile-launch",
|
||||
"invalidUrlHint": "Введите корректный URL http:// или https://."
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Управление Cookie",
|
||||
@@ -1065,6 +1125,48 @@
|
||||
"description": "Введите имя для клонированного профиля",
|
||||
"namePlaceholder": "Имя профиля",
|
||||
"button": "Клонировать"
|
||||
},
|
||||
"duplicate": "Дублировать",
|
||||
"breadcrumbRoot": "Профиль",
|
||||
"openDialog": "Открыть настройки",
|
||||
"sections": {
|
||||
"overview": "Обзор",
|
||||
"fingerprint": "Отпечаток",
|
||||
"network": "Сеть",
|
||||
"cookies": "Cookie",
|
||||
"extensions": "Расширения",
|
||||
"sync": "Синхронизация",
|
||||
"automation": "Автоматизация",
|
||||
"security": "Безопасность",
|
||||
"delete": "Удалить профиль",
|
||||
"activity": "Активность",
|
||||
"launchHook": "Хук запуска"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Настройте, как этот профиль выглядит для скриптов отпечатков.",
|
||||
"network": "Управляйте прокси или VPN, используемым этим профилем.",
|
||||
"cookies": "Импортируйте, копируйте или удаляйте cookie этого профиля.",
|
||||
"extensions": "Выберите, какие расширения загружать с этим профилем.",
|
||||
"sync": "Настройте репликацию этого профиля между устройствами.",
|
||||
"automation": "Запускайте команду при старте этого профиля.",
|
||||
"security": "Зашифруйте данные профиля паролем.",
|
||||
"launchHook": "Отправлять GET-запрос на этот URL при каждом запуске профиля."
|
||||
},
|
||||
"badges": {
|
||||
"locked": "ЗАБЛОК.",
|
||||
"active": "АКТИВЕН"
|
||||
},
|
||||
"cookies": {
|
||||
"runningNotice": "Куки нельзя прочитать, пока браузер запущен. Сначала закройте этот профиль.",
|
||||
"domainsHeader": "Домены ({{count}})"
|
||||
},
|
||||
"security": {
|
||||
"protected": "Этот профиль зашифрован паролем.",
|
||||
"unprotected": "Этот профиль не зашифрован. Задайте пароль, чтобы зашифровать его данные.",
|
||||
"cannotWhileRunning": "Остановите профиль перед сменой пароля."
|
||||
},
|
||||
"fingerprint": {
|
||||
"notSupported": "Редактирование отпечатков доступно только для профилей Camoufox и Wayfern."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1124,7 +1226,9 @@
|
||||
"syncEnabled": "Синхронизация включена",
|
||||
"syncDisabled": "Синхронизация отключена",
|
||||
"syncEnableTooltip": "Включить синхронизацию",
|
||||
"syncDisableTooltip": "Отключить синхронизацию"
|
||||
"syncDisableTooltip": "Отключить синхронизацию",
|
||||
"loadGroupsFailed": "Не удалось загрузить группы расширений",
|
||||
"assignGroupFailed": "Не удалось назначить группу расширений"
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
@@ -1256,12 +1360,11 @@
|
||||
"importedSuccess": "Профиль «{{name}}» успешно импортирован",
|
||||
"notInstalled": "{{browser}} не установлен. Сначала загрузите {{browser}} из главного окна, затем попробуйте импортировать снова.",
|
||||
"importFailed": "Не удалось импортировать профиль: {{error}}",
|
||||
"importedAsPrefix": "Этот профиль будет импортирован как профиль",
|
||||
"importedAsSuffix": ".",
|
||||
"proxyOptional": "Прокси (необязательно)",
|
||||
"noProxy": "Без прокси",
|
||||
"nextButton": "Далее",
|
||||
"importButton": "Импорт"
|
||||
"importButton": "Импорт",
|
||||
"importedAs": "Этот профиль будет импортирован как профиль {{browser}}."
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Синхронизация...",
|
||||
@@ -1424,7 +1527,11 @@
|
||||
"grantAccessButton": "Предоставить доступ",
|
||||
"requestSuccessMicrophone": "Запрошен доступ к микрофону",
|
||||
"requestSuccessCamera": "Запрошен доступ к камере",
|
||||
"requestFailed": "Не удалось запросить разрешение"
|
||||
"requestFailed": "Не удалось запросить разрешение",
|
||||
"stillNotGrantedMicrophone": "Доступ к микрофону всё ещё не предоставлен. Возможно, потребуется включить его вручную в Системных настройках → Конфиденциальность и безопасность → Микрофон.",
|
||||
"stillNotGrantedCamera": "Доступ к камере всё ещё не предоставлен. Возможно, потребуется включить его вручную в Системных настройках → Конфиденциальность и безопасность → Камера.",
|
||||
"grantedToastMicrophone": "Доступ к микрофону предоставлен",
|
||||
"grantedToastCamera": "Доступ к камере предоставлен"
|
||||
},
|
||||
"traffic": {
|
||||
"title": "Подробности трафика",
|
||||
@@ -1503,7 +1610,12 @@
|
||||
"syncTooltipNotSynced": "Не синхронизировано",
|
||||
"noTags": "Нет тегов",
|
||||
"syncTooltipCloseToSync": "Закройте профиль для синхронизации",
|
||||
"syncTooltipDisabledWithLast": "Синхронизация отключена, последняя синхронизация {{time}}"
|
||||
"syncTooltipDisabledWithLast": "Синхронизация отключена, последняя синхронизация {{time}}",
|
||||
"addTagsPlaceholder": "Добавить теги",
|
||||
"tagsHeader": "Теги",
|
||||
"noteHeader": "Заметка",
|
||||
"vpnsHeading": "VPN",
|
||||
"createByCountryHeading": "Создать по стране"
|
||||
},
|
||||
"releaseTypeSelector": {
|
||||
"noReleaseTypes": "Нет доступных типов выпусков.",
|
||||
@@ -1521,7 +1633,14 @@
|
||||
"appUpdate": {
|
||||
"toast": {
|
||||
"updateFailed": "Не удалось обновить Donut Browser",
|
||||
"restartFailed": "Не удалось перезапустить"
|
||||
"restartFailed": "Не удалось перезапустить",
|
||||
"updateReady": "Обновление готово, перезапустите для применения",
|
||||
"manualDownloadRequired": "Требуется ручная загрузка",
|
||||
"restartNow": "Перезапустить сейчас",
|
||||
"viewRelease": "Посмотреть релиз",
|
||||
"later": "Позже",
|
||||
"uploading": "Загрузка",
|
||||
"downloading": "Скачивание"
|
||||
}
|
||||
},
|
||||
"browserDownload": {
|
||||
@@ -1532,7 +1651,10 @@
|
||||
"downloadFailed": "Не удалось загрузить {{browser}} {{version}}",
|
||||
"calculating": "вычисление...",
|
||||
"extractionFailed": "{{browser}} {{version}}: ошибка распаковки",
|
||||
"extractionFailedDescription": "Повреждённый файл удалён. Он будет повторно загружен при следующей попытке."
|
||||
"extractionFailedDescription": "Повреждённый файл удалён. Он будет повторно загружен при следующей попытке.",
|
||||
"extracting": "Распаковка файлов браузера... Не закрывайте приложение.",
|
||||
"verifying": "Проверка файлов браузера...",
|
||||
"downloadingRolling": "Загрузка rolling release сборки..."
|
||||
}
|
||||
},
|
||||
"versionUpdater": {
|
||||
@@ -1552,5 +1674,156 @@
|
||||
"upToDateDescription": "Все версии браузеров актуальны",
|
||||
"updateAllFailed": "Не удалось обновить версии браузеров"
|
||||
}
|
||||
},
|
||||
"profilePassword": {
|
||||
"set": {
|
||||
"title": "Установить пароль профиля",
|
||||
"description": "Шифрует данные {{name}} на диске. Этот пароль потребуется при каждом запуске профиля.",
|
||||
"button": "Зашифровать профиль"
|
||||
},
|
||||
"unlock": {
|
||||
"title": "Разблокировать профиль",
|
||||
"description": "Введите пароль, чтобы разблокировать {{name}}.",
|
||||
"button": "Разблокировать"
|
||||
},
|
||||
"change": {
|
||||
"title": "Изменить пароль профиля",
|
||||
"description": "Перезашифровать {{name}} с новым паролем.",
|
||||
"button": "Изменить пароль"
|
||||
},
|
||||
"remove": {
|
||||
"title": "Удалить пароль профиля",
|
||||
"description": "Расшифровывает данные {{name}} на диске. Профиль больше не будет защищён паролем.",
|
||||
"button": "Удалить пароль"
|
||||
},
|
||||
"fields": {
|
||||
"password": "Пароль",
|
||||
"currentPassword": "Текущий пароль",
|
||||
"newPassword": "Новый пароль",
|
||||
"confirm": "Подтвердите пароль",
|
||||
"confirmPassword": "Подтвердите новый пароль"
|
||||
},
|
||||
"errors": {
|
||||
"oldPasswordRequired": "Требуется текущий пароль",
|
||||
"passwordRequired": "Требуется пароль",
|
||||
"tooShort": "Пароль должен содержать не менее 8 символов",
|
||||
"mismatch": "Пароли не совпадают"
|
||||
},
|
||||
"toasts": {
|
||||
"set": "Профиль защищён паролем",
|
||||
"changed": "Пароль профиля изменён",
|
||||
"removed": "Пароль профиля удалён"
|
||||
},
|
||||
"warnings": {
|
||||
"forgetWarningTitle": "Важно: пароль восстановить нельзя",
|
||||
"forgetWarningBody": "Donut Browser не может сбросить, восстановить или обойти этот пароль. Если вы его забудете, доступ к данным этого профиля будет утрачен навсегда."
|
||||
},
|
||||
"modes": {
|
||||
"set": "Задать",
|
||||
"change": "Изменить",
|
||||
"remove": "Удалить"
|
||||
}
|
||||
},
|
||||
"backendErrors": {
|
||||
"incorrectPassword": "Неверный пароль",
|
||||
"lockedOut": "Слишком много неудачных попыток. Повторите через {{duration}}.",
|
||||
"lockedOutDuration": {
|
||||
"seconds": "{{seconds}}с",
|
||||
"minutes": "{{minutes}} мин",
|
||||
"hours": "{{hours}} ч"
|
||||
},
|
||||
"profileNotFound": "Профиль не найден",
|
||||
"profileNotProtected": "Профиль не защищён паролем",
|
||||
"profileAlreadyProtected": "Профиль уже защищён паролем",
|
||||
"profileRunning": "Невозможно выполнить это действие, пока профиль запущен",
|
||||
"profileEphemeral": "Эфемерные профили не могут быть защищены паролем — их данные удаляются при выходе.",
|
||||
"profileMissingSalt": "У профиля отсутствует соль шифрования",
|
||||
"profileLocked": "Профиль заблокирован. Сначала введите пароль.",
|
||||
"invalidProfileId": "Недействительный идентификатор профиля",
|
||||
"passwordTooShort": "Пароль должен быть не короче {{min}} символов",
|
||||
"internal": "Что-то пошло не так: {{detail}}",
|
||||
"invalidLaunchHookUrl": "Неверный URL хука запуска. Используйте полный URL http:// или https://.",
|
||||
"cookieDbLocked": "Не удалось прочитать куки — база данных заблокирована. Закройте браузер и попробуйте снова.",
|
||||
"cookieDbUnavailable": "Не удалось прочитать куки — хранилище куки недоступно.",
|
||||
"selfHostedRequiresLogout": "Выйдите из аккаунта Donut, прежде чем настраивать собственный сервер."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Профили",
|
||||
"proxies": "Прокси",
|
||||
"extensions": "Расширения",
|
||||
"groups": "Группы",
|
||||
"settings": "Настройки",
|
||||
"more": {
|
||||
"label": "Ещё",
|
||||
"closeAriaLabel": "Закрыть меню",
|
||||
"importProfile": "Импорт профиля",
|
||||
"importProfileHint": "Перенести профили из другого инструмента",
|
||||
"integrations": "Интеграции",
|
||||
"integrationsHint": "Slack, MCP, автоматизации",
|
||||
"account": "Аккаунт",
|
||||
"accountHint": "Облако, оплата, вход"
|
||||
}
|
||||
},
|
||||
"pageTitle": {
|
||||
"proxies": "Прокси",
|
||||
"extensions": "Расширения",
|
||||
"groups": "Группы",
|
||||
"vpns": "VPN",
|
||||
"settings": "Настройки",
|
||||
"integrations": "Интеграции",
|
||||
"account": "Аккаунт",
|
||||
"import": "Импорт профиля"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
"title": "Синхронизация приостановлена — нужен пароль",
|
||||
"description": "Загружены зашифрованные данные, но на этом устройстве не задан E2E-пароль. Откройте Настройки → Шифрование и введите пароль, чтобы продолжить синхронизацию.",
|
||||
"openSettings": "Открыть настройки"
|
||||
},
|
||||
"rollover": {
|
||||
"startedTitle": "Перешифровываем ваши данные",
|
||||
"startedDescription": "Мы заново загружаем каждый синхронизированный элемент под новым паролем. Сначала профили, затем прокси, группы, VPN и расширения.",
|
||||
"progressTitle": "Перешифровка: {{stage}}",
|
||||
"progressDescription": "{{done}} из {{total}}",
|
||||
"completedTitle": "Перешифровка завершена",
|
||||
"completedDescription": "Все синхронизированные данные запечатаны новым паролем.",
|
||||
"stage": {
|
||||
"profiles": "профили",
|
||||
"proxies": "прокси",
|
||||
"groups": "группы",
|
||||
"vpns": "VPN",
|
||||
"extensions": "расширения",
|
||||
"extension_groups": "группы расширений"
|
||||
}
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"refreshed": "Аккаунт обновлён",
|
||||
"loggedOut": "Вы вышли",
|
||||
"signedOut": "Не выполнен вход",
|
||||
"signedOutDescription": "Войдите, чтобы включить облачную синхронизацию, зашифрованные профили и командные функции.",
|
||||
"plan": "Тариф: {{plan}} · {{period}}",
|
||||
"refresh": "Обновить",
|
||||
"logout": "Выйти",
|
||||
"signIn": "Войти",
|
||||
"fields": {
|
||||
"plan": "Тариф",
|
||||
"status": "Статус",
|
||||
"teamRole": "Роль в команде",
|
||||
"period": "Период"
|
||||
},
|
||||
"tabs": {
|
||||
"account": "Аккаунт",
|
||||
"selfHosted": "Свой сервер"
|
||||
},
|
||||
"selfHosted": {
|
||||
"title": "Свой сервер синхронизации",
|
||||
"description": "Подключите Donut к собственному серверу donut-sync, чтобы синхронизировать профили, прокси, группы и расширения без использования облака.",
|
||||
"disabledWhileLoggedIn": "Свой сервер синхронизации недоступен, пока вы вошли в аккаунт Donut. Выйдите из аккаунта, чтобы использовать собственный сервер.",
|
||||
"connectionStatus": "Соединение:",
|
||||
"statusUnknown": "Не проверено",
|
||||
"testConnection": "Проверить соединение",
|
||||
"disconnect": "Отключить"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+314
-41
@@ -30,7 +30,9 @@
|
||||
"saveSettings": "保存设置",
|
||||
"moreInfo": "了解更多",
|
||||
"downloading": "下载中...",
|
||||
"minimize": "最小化"
|
||||
"minimize": "最小化",
|
||||
"saving": "正在保存…",
|
||||
"saved": "已保存"
|
||||
},
|
||||
"status": {
|
||||
"active": "活跃",
|
||||
@@ -60,7 +62,8 @@
|
||||
"optional": "可选",
|
||||
"required": "必填",
|
||||
"unknownProfile": "未知",
|
||||
"mode": "模式"
|
||||
"mode": "模式",
|
||||
"never": "从不"
|
||||
},
|
||||
"time": {
|
||||
"days": "天",
|
||||
@@ -72,7 +75,11 @@
|
||||
"aria": {
|
||||
"selectAll": "全选",
|
||||
"selectRow": "选择行",
|
||||
"selectProfile": "选择配置文件"
|
||||
"selectProfile": "选择配置文件",
|
||||
"copy": "复制到剪贴板",
|
||||
"copied": "已复制",
|
||||
"showToken": "显示令牌",
|
||||
"hideToken": "隐藏令牌"
|
||||
},
|
||||
"keys": {
|
||||
"escape": "Esc"
|
||||
@@ -87,7 +94,11 @@
|
||||
"title": "命令面板",
|
||||
"description": "搜索要执行的命令..."
|
||||
},
|
||||
"noResults": "未找到结果。"
|
||||
"noResults": "未找到结果。",
|
||||
"srOnly": {
|
||||
"copy": "复制",
|
||||
"copied": "已复制"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
@@ -152,18 +163,26 @@
|
||||
"commercial": {
|
||||
"title": "商业许可",
|
||||
"trialActive": "试用期:剩余 {{days}} 天 {{hours}} 小时",
|
||||
"trialActiveDescription": "试用期内商业使用免费",
|
||||
"trialActiveDescription": "试用期内商业使用免费。试用期结束后,所有功能继续正常使用 — 个人使用仍然免费,只有商业使用需要许可证。",
|
||||
"trialExpired": "试用期已过期",
|
||||
"trialExpiredDescription": "个人使用仍然免费。商业使用需要许可证。"
|
||||
"trialExpiredDescription": "个人使用仍然免费。商业使用需要许可证。",
|
||||
"subscriptionActive": "已订阅 — {{plan}} 方案",
|
||||
"subscriptionActiveDescription": "您的 Donut Browser 订阅已激活。在订阅有效期内允许商业使用。"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "高级",
|
||||
"clearCache": "清除所有版本缓存",
|
||||
"clearCacheDescription": "清除所有缓存的浏览器版本数据并从源刷新所有浏览器版本。这将强制重新下载所有浏览器的版本信息。",
|
||||
"clearCacheFailed": "清除缓存失败"
|
||||
"clearCacheFailed": "清除缓存失败",
|
||||
"copyLogs": "复制日志",
|
||||
"openLogDir": "打开日志文件夹",
|
||||
"copyLogsSuccess": "日志已复制到剪贴板",
|
||||
"copyLogsDescription": "将最近的日志文件(最多 5 MB)合并到剪贴板,便于在反馈问题时分享。"
|
||||
},
|
||||
"disableAutoUpdates": "禁用应用自动更新",
|
||||
"disableAutoUpdatesDescription": "阻止应用程序自动检查和安装 Donut Browser 更新。浏览器更新不受影响。"
|
||||
"disableAutoUpdatesDescription": "阻止应用程序自动检查和安装 Donut Browser 更新。浏览器更新不受影响。",
|
||||
"keepDecryptedProfilesInRam": "在内存中保留已解密的配置文件",
|
||||
"keepDecryptedProfilesInRamDescription": "在启动之间保留密码保护配置文件的已解密内存副本,以便更快地启动。无论如何磁盘上的副本始终保持加密。"
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "搜索配置文件...",
|
||||
@@ -178,7 +197,11 @@
|
||||
"integrations": "集成",
|
||||
"importProfile": "导入配置文件",
|
||||
"extensions": "扩展程序"
|
||||
}
|
||||
},
|
||||
"newProfile": "新建",
|
||||
"donutLogo": "Donut Browser 标识",
|
||||
"scrollGroupsLeft": "向左滚动分组",
|
||||
"scrollGroupsRight": "向右滚动分组"
|
||||
},
|
||||
"profiles": {
|
||||
"title": "配置文件",
|
||||
@@ -196,7 +219,14 @@
|
||||
"group": "分组",
|
||||
"proxy": "代理 / VPN",
|
||||
"lastLaunch": "最后启动",
|
||||
"empty": "未找到配置文件。"
|
||||
"empty": "未找到配置文件。",
|
||||
"notSelected": "未选择",
|
||||
"ext": "扩展",
|
||||
"dns": "DNS",
|
||||
"extDefault": "默认",
|
||||
"dnsLevel": "DNS 屏蔽列表: {{level}}",
|
||||
"extSearch": "搜索分组…",
|
||||
"extEmpty": "没有扩展组"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "启动",
|
||||
@@ -211,7 +241,10 @@
|
||||
"assignToGroup": "分配到组",
|
||||
"changeFingerprint": "更改指纹",
|
||||
"copyCookiesToProfile": "复制 Cookies 到配置文件",
|
||||
"launchHook": "启动钩子 URL"
|
||||
"launchHook": "启动钩子 URL",
|
||||
"setPassword": "设置密码",
|
||||
"changePassword": "更改密码",
|
||||
"removePassword": "移除密码"
|
||||
},
|
||||
"synchronizer": {
|
||||
"launchWithSync": "使用同步器启动",
|
||||
@@ -230,9 +263,8 @@
|
||||
"flakyTooltip": "此配置文件的屏幕分辨率与领导者不同。页面布局可能不同,导致点击和交互可能命中错误的元素。"
|
||||
},
|
||||
"ephemeral": "临时",
|
||||
"ephemeralDescription": "浏览器被强制将配置数据写入内存而非磁盘。关闭浏览器时数据将被删除。",
|
||||
"ephemeralDescription": "浏览器被强制将配置文件数据写入内存而不是磁盘。请注意,在系统负载较高时,操作系统可能会将部分内存换出到磁盘(swap),因此会话的某些痕迹仍可能被恢复。",
|
||||
"ephemeralBadge": "临时",
|
||||
"ephemeralAlpha": "Alpha",
|
||||
"bulkDelete": {
|
||||
"title": "删除所选配置文件",
|
||||
"description": "此操作无法撤销。这将永久删除 {{count}} 个配置文件及其关联的所有数据。",
|
||||
@@ -255,6 +287,10 @@
|
||||
"assignProxy": "分配代理",
|
||||
"assignExtensionGroup": "分配扩展分组",
|
||||
"copyCookies": "复制 Cookie"
|
||||
},
|
||||
"passwordProtectedBadge": "密码保护",
|
||||
"launchHook": {
|
||||
"placeholder": "https://example.com/track-launch"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -302,7 +338,11 @@
|
||||
"firefoxLabel": "Firefox",
|
||||
"firefoxSubtitle": "由 Camoufox 驱动",
|
||||
"camoufoxWarning": "Firefox(Camoufox)由第三方组织维护。在生产环境中,请使用 Chromium。",
|
||||
"platformUnavailable": "{{browser}} 在您的平台上尚不可用。"
|
||||
"platformUnavailable": "{{browser}} 在您的平台上尚不可用。",
|
||||
"passwordProtect": {
|
||||
"label": "为此配置文件设置密码保护",
|
||||
"description": "加密磁盘上的配置文件数据。启动时需要密码。"
|
||||
}
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "删除配置文件",
|
||||
@@ -488,7 +528,9 @@
|
||||
"deleteGroupAndProfiles": "删除组和配置文件",
|
||||
"loadProfilesFailed": "加载配置文件失败",
|
||||
"unknownGroup": "未知分组",
|
||||
"profileGroupsAriaLabel": "配置文件分组"
|
||||
"profileGroupsAriaLabel": "配置文件分组",
|
||||
"loading": "正在加载组...",
|
||||
"all": "全部"
|
||||
},
|
||||
"sync": {
|
||||
"mode": {
|
||||
@@ -540,6 +582,7 @@
|
||||
"openLogin": "登录",
|
||||
"linkCodeLabel": "登录代码",
|
||||
"linkCodePlaceholder": "粘贴网站的代码",
|
||||
"signInTitle": "登录",
|
||||
"verifyAndLogin": "验证并登录",
|
||||
"loggingIn": "登录中...",
|
||||
"connected": "已连接",
|
||||
@@ -631,7 +674,8 @@
|
||||
"mcpAcceptTermsFirst": "(请先在设置中接受 Wayfern 条款)",
|
||||
"mcpStarted": "MCP 服务器已在端口 {{port}} 上启动",
|
||||
"mcpStopped": "MCP 服务器已停止",
|
||||
"mcpToggleFailed": "切换 MCP 服务器失败"
|
||||
"mcpToggleFailed": "切换 MCP 服务器失败",
|
||||
"openSettings": "打开集成设置"
|
||||
},
|
||||
"import": {
|
||||
"title": "导入配置文件",
|
||||
@@ -711,6 +755,10 @@
|
||||
"webrtc": "阻止 WebRTC",
|
||||
"webgl": "阻止 WebGL"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
"browserBehavior": "浏览器行为",
|
||||
"allowAddonsOpenTabs": "允许浏览器附加组件自动打开新标签页"
|
||||
}
|
||||
},
|
||||
"cookies": {
|
||||
@@ -875,7 +923,9 @@
|
||||
"loadProxiesFailed": "加载代理失败: {{error}}",
|
||||
"setupProxyListenersFailed": "设置代理事件监听器失败: {{error}}",
|
||||
"loadVpnConfigsFailed": "加载 VPN 配置失败: {{error}}",
|
||||
"setupVpnListenersFailed": "设置 VPN 事件监听器失败: {{error}}"
|
||||
"setupVpnListenersFailed": "设置 VPN 事件监听器失败: {{error}}",
|
||||
"themeNotFound": "未找到 Tokyo Night 主题",
|
||||
"setProfilePasswordFailed": "设置配置文件密码失败: {{error}}"
|
||||
},
|
||||
"browser": {
|
||||
"camoufox": "Camoufox",
|
||||
@@ -901,15 +951,15 @@
|
||||
"blockWebRTC": "阻止 WebRTC",
|
||||
"blockWebGL": "阻止 WebGL",
|
||||
"navigatorProperties": "Navigator 属性",
|
||||
"userAgent": "User Agent",
|
||||
"userAgent": "用户代理",
|
||||
"userAgentAndPlatform": "User Agent 和平台",
|
||||
"platform": "平台",
|
||||
"platformVersion": "平台版本",
|
||||
"appVersion": "应用版本",
|
||||
"osCpu": "OS CPU",
|
||||
"osCpu": "操作系统 CPU",
|
||||
"hardwareConcurrency": "硬件并发数",
|
||||
"maxTouchPoints": "最大触摸点数",
|
||||
"doNotTrack": "Do Not Track",
|
||||
"doNotTrack": "请勿跟踪",
|
||||
"selectDntPlaceholder": "选择 DNT 值",
|
||||
"dntAllowed": "0(允许跟踪)",
|
||||
"dntNotAllowed": "1(不允许跟踪)",
|
||||
@@ -931,8 +981,8 @@
|
||||
"outerHeight": "外部高度",
|
||||
"innerWidth": "内部宽度",
|
||||
"innerHeight": "内部高度",
|
||||
"screenX": "Screen X",
|
||||
"screenY": "Screen Y",
|
||||
"screenX": "屏幕 X",
|
||||
"screenY": "屏幕 Y",
|
||||
"geolocation": "地理位置",
|
||||
"timezoneAndGeolocation": "时区和地理位置",
|
||||
"timezoneGeolocationDescription": "这些值会覆盖浏览器的时区和地理位置 API。",
|
||||
@@ -946,15 +996,15 @@
|
||||
"region": "地区",
|
||||
"script": "脚本",
|
||||
"webglProperties": "WebGL 属性",
|
||||
"webglVendor": "WebGL Vendor",
|
||||
"webglRenderer": "WebGL Renderer",
|
||||
"webglVendor": "WebGL 供应商",
|
||||
"webglRenderer": "WebGL 渲染器",
|
||||
"webglParameters": "WebGL 参数",
|
||||
"webglParametersJson": "WebGL 参数 (JSON)",
|
||||
"webgl2Parameters": "WebGL2 参数",
|
||||
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
|
||||
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
|
||||
"webglShaderPrecisionFormats": "WebGL 着色器精度格式",
|
||||
"webgl2ShaderPrecisionFormats": "WebGL2 着色器精度格式",
|
||||
"canvasFingerprint": "Canvas 指纹",
|
||||
"canvasNoiseSeed": "Canvas Noise Seed",
|
||||
"canvasNoiseSeed": "Canvas 噪声种子",
|
||||
"canvasNoiseSeedDescription": "此种子用于生成一致但唯一的 Canvas 指纹。每个配置文件应使用不同的种子。",
|
||||
"fonts": "字体",
|
||||
"fontsJson": "字体 (JSON 数组)",
|
||||
@@ -975,8 +1025,8 @@
|
||||
"maxChannelCount": "最大通道数",
|
||||
"vendorInfo": "供应商信息",
|
||||
"vendor": "供应商",
|
||||
"vendorSub": "Vendor Sub",
|
||||
"productSub": "Product Sub",
|
||||
"vendorSub": "供应商子版本",
|
||||
"productSub": "产品子版本",
|
||||
"brand": "品牌",
|
||||
"brandVersion": "品牌版本",
|
||||
"proFeature": "这是 Pro 功能",
|
||||
@@ -1033,13 +1083,22 @@
|
||||
"lastLaunched": "上次启动",
|
||||
"hostOs": "主机操作系统",
|
||||
"ephemeral": "临时",
|
||||
"extensionGroup": "扩展程序组"
|
||||
"extensionGroup": "扩展程序组",
|
||||
"totalSessions": "总会话",
|
||||
"syncMode": "同步模式",
|
||||
"proxy": "代理",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "存储的 Cookie",
|
||||
"localDataTransfer": "本地数据传输"
|
||||
},
|
||||
"values": {
|
||||
"none": "无",
|
||||
"never": "从未",
|
||||
"copied": "已复制!",
|
||||
"yes": "是"
|
||||
"yes": "是",
|
||||
"activeNow": "当前活动",
|
||||
"direct": "直连",
|
||||
"loading": "加载中…"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "代理绕过规则",
|
||||
@@ -1053,8 +1112,9 @@
|
||||
"launchHook": {
|
||||
"title": "启动钩子 URL",
|
||||
"label": "启动钩子 URL",
|
||||
"description": "每次启动配置文件时,Donut Browser 都会向此 URL 发送 POST 请求。",
|
||||
"placeholder": "https://example.com/hooks/profile-launch"
|
||||
"description": "每次启动配置文件时,Donut Browser 都会向此 URL 发送一个 GET 请求。",
|
||||
"placeholder": "https://example.com/hooks/profile-launch",
|
||||
"invalidUrlHint": "请输入有效的 http:// 或 https:// URL。"
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "管理 Cookie",
|
||||
@@ -1065,6 +1125,48 @@
|
||||
"description": "输入克隆配置文件的名称",
|
||||
"namePlaceholder": "配置文件名称",
|
||||
"button": "克隆"
|
||||
},
|
||||
"duplicate": "复制",
|
||||
"breadcrumbRoot": "配置文件",
|
||||
"openDialog": "打开设置",
|
||||
"sections": {
|
||||
"overview": "概览",
|
||||
"fingerprint": "指纹",
|
||||
"network": "网络",
|
||||
"cookies": "Cookie",
|
||||
"extensions": "扩展",
|
||||
"sync": "同步",
|
||||
"automation": "自动化",
|
||||
"security": "安全",
|
||||
"delete": "删除配置文件",
|
||||
"activity": "活动",
|
||||
"launchHook": "启动钩子"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "配置此配置文件如何对指纹脚本显示。",
|
||||
"network": "管理此配置文件使用的代理或 VPN。",
|
||||
"cookies": "导入、复制或清除此配置文件的 Cookie。",
|
||||
"extensions": "选择启动此配置文件时加载的扩展。",
|
||||
"sync": "配置此配置文件如何在设备间同步。",
|
||||
"automation": "在启动此配置文件时运行命令。",
|
||||
"security": "用密码加密配置文件数据。",
|
||||
"launchHook": "每次启动配置文件时向此 URL 发送一个 GET 请求。"
|
||||
},
|
||||
"badges": {
|
||||
"locked": "已锁",
|
||||
"active": "已启用"
|
||||
},
|
||||
"cookies": {
|
||||
"runningNotice": "浏览器运行时无法读取 Cookie。请先关闭此配置文件。",
|
||||
"domainsHeader": "域 ({{count}})"
|
||||
},
|
||||
"security": {
|
||||
"protected": "此配置文件已用密码加密。",
|
||||
"unprotected": "此配置文件未加密。设置密码以加密其数据。",
|
||||
"cannotWhileRunning": "更改密码前请先停止此配置文件。"
|
||||
},
|
||||
"fingerprint": {
|
||||
"notSupported": "指纹编辑仅适用于 Camoufox 和 Wayfern 配置文件。"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
@@ -1124,7 +1226,9 @@
|
||||
"syncEnabled": "同步已启用",
|
||||
"syncDisabled": "同步已禁用",
|
||||
"syncEnableTooltip": "启用同步",
|
||||
"syncDisableTooltip": "禁用同步"
|
||||
"syncDisableTooltip": "禁用同步",
|
||||
"loadGroupsFailed": "加载扩展组失败",
|
||||
"assignGroupFailed": "分配扩展组失败"
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
@@ -1256,12 +1360,11 @@
|
||||
"importedSuccess": "已成功导入配置文件「{{name}}」",
|
||||
"notInstalled": "{{browser}} 未安装。请先从主窗口下载 {{browser}},然后再尝试导入。",
|
||||
"importFailed": "导入配置文件失败: {{error}}",
|
||||
"importedAsPrefix": "此配置文件将作为以下配置文件导入:",
|
||||
"importedAsSuffix": "",
|
||||
"proxyOptional": "代理 (可选)",
|
||||
"noProxy": "无代理",
|
||||
"nextButton": "下一步",
|
||||
"importButton": "导入"
|
||||
"importButton": "导入",
|
||||
"importedAs": "此配置文件将作为 {{browser}} 配置文件导入。"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "同步中...",
|
||||
@@ -1424,7 +1527,11 @@
|
||||
"grantAccessButton": "授予访问",
|
||||
"requestSuccessMicrophone": "已请求麦克风访问",
|
||||
"requestSuccessCamera": "已请求摄像头访问",
|
||||
"requestFailed": "请求权限失败"
|
||||
"requestFailed": "请求权限失败",
|
||||
"stillNotGrantedMicrophone": "麦克风访问权限仍未授予。您可能需要在系统设置 → 隐私与安全 → 麦克风中手动启用。",
|
||||
"stillNotGrantedCamera": "摄像头访问权限仍未授予。您可能需要在系统设置 → 隐私与安全 → 摄像头中手动启用。",
|
||||
"grantedToastMicrophone": "已授予麦克风访问权限",
|
||||
"grantedToastCamera": "已授予摄像头访问权限"
|
||||
},
|
||||
"traffic": {
|
||||
"title": "流量详情",
|
||||
@@ -1503,7 +1610,12 @@
|
||||
"syncTooltipNotSynced": "未同步",
|
||||
"noTags": "无标签",
|
||||
"syncTooltipCloseToSync": "关闭配置文件以进行同步",
|
||||
"syncTooltipDisabledWithLast": "同步已禁用,上次同步 {{time}}"
|
||||
"syncTooltipDisabledWithLast": "同步已禁用,上次同步 {{time}}",
|
||||
"addTagsPlaceholder": "添加标签",
|
||||
"tagsHeader": "标签",
|
||||
"noteHeader": "备注",
|
||||
"vpnsHeading": "VPN",
|
||||
"createByCountryHeading": "按国家创建"
|
||||
},
|
||||
"releaseTypeSelector": {
|
||||
"noReleaseTypes": "没有可用的发布类型。",
|
||||
@@ -1521,7 +1633,14 @@
|
||||
"appUpdate": {
|
||||
"toast": {
|
||||
"updateFailed": "更新 Donut Browser 失败",
|
||||
"restartFailed": "重启失败"
|
||||
"restartFailed": "重启失败",
|
||||
"updateReady": "更新就绪,请重启以应用",
|
||||
"manualDownloadRequired": "需要手动下载",
|
||||
"restartNow": "立即重启",
|
||||
"viewRelease": "查看版本",
|
||||
"later": "稍后",
|
||||
"uploading": "上传中",
|
||||
"downloading": "下载中"
|
||||
}
|
||||
},
|
||||
"browserDownload": {
|
||||
@@ -1532,7 +1651,10 @@
|
||||
"downloadFailed": "下载 {{browser}} {{version}} 失败",
|
||||
"calculating": "计算中...",
|
||||
"extractionFailed": "{{browser}} {{version}}: 解压失败",
|
||||
"extractionFailedDescription": "损坏的文件已删除。下次尝试时将重新下载。"
|
||||
"extractionFailedDescription": "损坏的文件已删除。下次尝试时将重新下载。",
|
||||
"extracting": "正在提取浏览器文件...请不要关闭应用。",
|
||||
"verifying": "正在验证浏览器文件...",
|
||||
"downloadingRolling": "正在下载滚动发布版本..."
|
||||
}
|
||||
},
|
||||
"versionUpdater": {
|
||||
@@ -1552,5 +1674,156 @@
|
||||
"upToDateDescription": "所有浏览器版本都是最新的",
|
||||
"updateAllFailed": "更新浏览器版本失败"
|
||||
}
|
||||
},
|
||||
"profilePassword": {
|
||||
"set": {
|
||||
"title": "设置配置文件密码",
|
||||
"description": "加密 {{name}} 的磁盘数据。每次启动配置文件时都需要输入此密码。",
|
||||
"button": "加密配置文件"
|
||||
},
|
||||
"unlock": {
|
||||
"title": "解锁配置文件",
|
||||
"description": "输入密码以解锁 {{name}}。",
|
||||
"button": "解锁"
|
||||
},
|
||||
"change": {
|
||||
"title": "更改配置文件密码",
|
||||
"description": "使用新密码重新加密 {{name}}。",
|
||||
"button": "更改密码"
|
||||
},
|
||||
"remove": {
|
||||
"title": "移除配置文件密码",
|
||||
"description": "解密 {{name}} 的磁盘数据。配置文件将不再受密码保护。",
|
||||
"button": "移除密码"
|
||||
},
|
||||
"fields": {
|
||||
"password": "密码",
|
||||
"currentPassword": "当前密码",
|
||||
"newPassword": "新密码",
|
||||
"confirm": "确认密码",
|
||||
"confirmPassword": "确认新密码"
|
||||
},
|
||||
"errors": {
|
||||
"oldPasswordRequired": "需要当前密码",
|
||||
"passwordRequired": "需要密码",
|
||||
"tooShort": "密码至少需要 8 个字符",
|
||||
"mismatch": "密码不匹配"
|
||||
},
|
||||
"toasts": {
|
||||
"set": "配置文件现在受密码保护",
|
||||
"changed": "配置文件密码已更改",
|
||||
"removed": "配置文件密码已移除"
|
||||
},
|
||||
"warnings": {
|
||||
"forgetWarningTitle": "重要:此密码无法恢复",
|
||||
"forgetWarningBody": "Donut Browser 无法重置、恢复或绕过此密码。如果忘记,您将永久无法访问此配置文件的数据。"
|
||||
},
|
||||
"modes": {
|
||||
"set": "设置",
|
||||
"change": "更改",
|
||||
"remove": "删除"
|
||||
}
|
||||
},
|
||||
"backendErrors": {
|
||||
"incorrectPassword": "密码不正确",
|
||||
"lockedOut": "尝试次数过多。请在 {{duration}} 后重试。",
|
||||
"lockedOutDuration": {
|
||||
"seconds": "{{seconds}}秒",
|
||||
"minutes": "{{minutes}} 分钟",
|
||||
"hours": "{{hours}} 小时"
|
||||
},
|
||||
"profileNotFound": "未找到配置文件",
|
||||
"profileNotProtected": "配置文件未受密码保护",
|
||||
"profileAlreadyProtected": "配置文件已受密码保护",
|
||||
"profileRunning": "配置文件运行时无法执行此操作",
|
||||
"profileEphemeral": "临时配置文件无法设置密码 — 退出时数据会被清除。",
|
||||
"profileMissingSalt": "配置文件缺少加密盐",
|
||||
"profileLocked": "配置文件已锁定。请先输入密码。",
|
||||
"invalidProfileId": "配置文件 ID 无效",
|
||||
"passwordTooShort": "密码至少需要 {{min}} 个字符",
|
||||
"internal": "出现问题:{{detail}}",
|
||||
"invalidLaunchHookUrl": "启动钩子 URL 无效。请使用完整的 http:// 或 https:// URL。",
|
||||
"cookieDbLocked": "无法读取 Cookie — 数据库已锁定。请关闭浏览器后重试。",
|
||||
"cookieDbUnavailable": "无法读取 Cookie — Cookie 存储不可用。",
|
||||
"selfHostedRequiresLogout": "在配置自托管服务器之前请先退出 Donut 账户。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "配置文件",
|
||||
"proxies": "代理",
|
||||
"extensions": "扩展",
|
||||
"groups": "分组",
|
||||
"settings": "设置",
|
||||
"more": {
|
||||
"label": "更多",
|
||||
"closeAriaLabel": "关闭菜单",
|
||||
"importProfile": "导入配置文件",
|
||||
"importProfileHint": "从其他工具导入",
|
||||
"integrations": "集成",
|
||||
"integrationsHint": "Slack、MCP、自动化",
|
||||
"account": "账户",
|
||||
"accountHint": "云、订阅、登录"
|
||||
}
|
||||
},
|
||||
"pageTitle": {
|
||||
"proxies": "代理",
|
||||
"extensions": "扩展",
|
||||
"groups": "分组",
|
||||
"vpns": "VPN",
|
||||
"settings": "设置",
|
||||
"integrations": "集成",
|
||||
"account": "账户",
|
||||
"import": "导入配置文件"
|
||||
},
|
||||
"encryption": {
|
||||
"required": {
|
||||
"title": "同步已暂停 — 需要密码",
|
||||
"description": "已下载加密数据,但此设备未设置 E2E 密码。打开 设置 → 加密 并输入密码以继续同步。",
|
||||
"openSettings": "打开设置"
|
||||
},
|
||||
"rollover": {
|
||||
"startedTitle": "正在重新加密您的数据",
|
||||
"startedDescription": "我们正在使用新密码重新上传每个已同步项。先是配置文件,然后是代理、分组、VPN 和扩展。",
|
||||
"progressTitle": "重新加密 {{stage}}",
|
||||
"progressDescription": "{{done}} / {{total}}",
|
||||
"completedTitle": "重新加密完成",
|
||||
"completedDescription": "所有已同步的数据都已使用新密码封装。",
|
||||
"stage": {
|
||||
"profiles": "配置文件",
|
||||
"proxies": "代理",
|
||||
"groups": "分组",
|
||||
"vpns": "VPN",
|
||||
"extensions": "扩展",
|
||||
"extension_groups": "扩展组"
|
||||
}
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"refreshed": "账户已刷新",
|
||||
"loggedOut": "已退出",
|
||||
"signedOut": "未登录",
|
||||
"signedOutDescription": "登录以启用云同步、加密配置文件和团队功能。",
|
||||
"plan": "套餐:{{plan}} · {{period}}",
|
||||
"refresh": "刷新",
|
||||
"logout": "退出",
|
||||
"signIn": "登录",
|
||||
"fields": {
|
||||
"plan": "套餐",
|
||||
"status": "状态",
|
||||
"teamRole": "团队角色",
|
||||
"period": "计费周期"
|
||||
},
|
||||
"tabs": {
|
||||
"account": "账户",
|
||||
"selfHosted": "自托管"
|
||||
},
|
||||
"selfHosted": {
|
||||
"title": "自托管同步服务器",
|
||||
"description": "将 Donut 连接到您自己的 donut-sync 服务器,无需使用托管云即可同步配置文件、代理、组和扩展程序。",
|
||||
"disabledWhileLoggedIn": "登录 Donut 账户时无法使用自托管同步。请先退出登录以使用自定义服务器。",
|
||||
"connectionStatus": "连接:",
|
||||
"statusUnknown": "未测试",
|
||||
"testConnection": "测试连接",
|
||||
"disconnect": "断开连接"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
/**
|
||||
* Backend error codes returned from Rust Tauri commands.
|
||||
* Keep this list in sync with the codes used in `src-tauri/src/profile/password.rs`.
|
||||
*/
|
||||
export type BackendErrorCode =
|
||||
| "INCORRECT_PASSWORD"
|
||||
| "LOCKED_OUT"
|
||||
| "PROFILE_NOT_FOUND"
|
||||
| "PROFILE_NOT_PROTECTED"
|
||||
| "PROFILE_ALREADY_PROTECTED"
|
||||
| "PROFILE_RUNNING"
|
||||
| "PROFILE_EPHEMERAL"
|
||||
| "PROFILE_MISSING_SALT"
|
||||
| "PROFILE_LOCKED"
|
||||
| "INVALID_PROFILE_ID"
|
||||
| "PASSWORD_TOO_SHORT"
|
||||
| "INVALID_LAUNCH_HOOK_URL"
|
||||
| "COOKIE_DB_LOCKED"
|
||||
| "COOKIE_DB_UNAVAILABLE"
|
||||
| "SELF_HOSTED_REQUIRES_LOGOUT"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
export interface BackendError {
|
||||
code: BackendErrorCode;
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse a backend error string as a structured `{code, params}` payload.
|
||||
* Returns null if the string isn't structured (e.g. raw error from a command
|
||||
* that doesn't yet emit codes — caller should fall back to showing the raw text).
|
||||
*/
|
||||
export function parseBackendError(err: unknown): BackendError | null {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (!message.startsWith("{")) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(message);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
typeof parsed.code === "string"
|
||||
) {
|
||||
return parsed as BackendError;
|
||||
}
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a backend error to a localized string. Falls back to the raw
|
||||
* message if the error isn't a structured backend error.
|
||||
*/
|
||||
export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
const parsed = parseBackendError(err);
|
||||
if (!parsed) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
switch (parsed.code) {
|
||||
case "INCORRECT_PASSWORD":
|
||||
return t("backendErrors.incorrectPassword");
|
||||
case "LOCKED_OUT": {
|
||||
const seconds = Number.parseInt(parsed.params?.seconds ?? "0", 10);
|
||||
return t("backendErrors.lockedOut", {
|
||||
duration: formatLockoutDuration(t, seconds),
|
||||
});
|
||||
}
|
||||
case "PROFILE_NOT_FOUND":
|
||||
return t("backendErrors.profileNotFound");
|
||||
case "PROFILE_NOT_PROTECTED":
|
||||
return t("backendErrors.profileNotProtected");
|
||||
case "PROFILE_ALREADY_PROTECTED":
|
||||
return t("backendErrors.profileAlreadyProtected");
|
||||
case "PROFILE_RUNNING":
|
||||
return t("backendErrors.profileRunning");
|
||||
case "PROFILE_EPHEMERAL":
|
||||
return t("backendErrors.profileEphemeral");
|
||||
case "PROFILE_MISSING_SALT":
|
||||
return t("backendErrors.profileMissingSalt");
|
||||
case "PROFILE_LOCKED":
|
||||
return t("backendErrors.profileLocked");
|
||||
case "INVALID_PROFILE_ID":
|
||||
return t("backendErrors.invalidProfileId");
|
||||
case "PASSWORD_TOO_SHORT": {
|
||||
const min = Number.parseInt(parsed.params?.min ?? "8", 10);
|
||||
return t("backendErrors.passwordTooShort", { min });
|
||||
}
|
||||
case "INVALID_LAUNCH_HOOK_URL":
|
||||
return t("backendErrors.invalidLaunchHookUrl");
|
||||
case "COOKIE_DB_LOCKED":
|
||||
return t("backendErrors.cookieDbLocked");
|
||||
case "COOKIE_DB_UNAVAILABLE":
|
||||
return t("backendErrors.cookieDbUnavailable");
|
||||
case "SELF_HOSTED_REQUIRES_LOGOUT":
|
||||
return t("backendErrors.selfHostedRequiresLogout");
|
||||
case "INTERNAL_ERROR":
|
||||
return t("backendErrors.internal", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
});
|
||||
default:
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatLockoutDuration(t: TFunction, seconds: number): string {
|
||||
if (seconds < 60)
|
||||
return t("backendErrors.lockedOutDuration.seconds", { seconds });
|
||||
const minutes = Math.ceil(seconds / 60);
|
||||
if (minutes < 60)
|
||||
return t("backendErrors.lockedOutDuration.minutes", { minutes });
|
||||
const hours = Math.ceil(minutes / 60);
|
||||
return t("backendErrors.lockedOutDuration.hours", { hours });
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the lockout countdown in seconds from a backend error, or null.
|
||||
*/
|
||||
export function extractLockoutSeconds(err: unknown): number | null {
|
||||
const parsed = parseBackendError(err);
|
||||
if (parsed?.code !== "LOCKED_OUT") return null;
|
||||
const secs = Number.parseInt(parsed.params?.seconds ?? "0", 10);
|
||||
return Number.isFinite(secs) && secs > 0 ? secs : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the error is a known structured backend error code.
|
||||
*/
|
||||
export function isBackendErrorCode(
|
||||
err: unknown,
|
||||
code: BackendErrorCode,
|
||||
): boolean {
|
||||
return parseBackendError(err)?.code === code;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FaFire,
|
||||
FaFirefox,
|
||||
} from "react-icons/fa";
|
||||
import { LuLock } from "react-icons/lu";
|
||||
|
||||
/**
|
||||
* Map internal browser names to display names
|
||||
@@ -42,7 +43,13 @@ export function getBrowserIcon(browserType: string) {
|
||||
export function getProfileIcon(profile: {
|
||||
browser: string;
|
||||
ephemeral?: boolean;
|
||||
password_protected?: boolean;
|
||||
}) {
|
||||
// `password_protected` and `ephemeral` are mutually exclusive (the backend
|
||||
// rejects setting a password on an ephemeral profile), so the order here
|
||||
// doesn't matter — checking lock first only matters if the invariant is
|
||||
// ever violated, in which case showing the lock is the safer default.
|
||||
if (profile.password_protected) return LuLock;
|
||||
if (profile.ephemeral) return FaFire;
|
||||
return getBrowserIcon(profile.browser);
|
||||
}
|
||||
|
||||
+446
-99
@@ -34,6 +34,38 @@ export interface Theme {
|
||||
}
|
||||
|
||||
export const THEMES: Theme[] = [
|
||||
{
|
||||
id: "donut-mono",
|
||||
name: "Donut Mono",
|
||||
colors: {
|
||||
"--background": "#070707",
|
||||
"--foreground": "#ffffff",
|
||||
"--card": "#0e0e0e",
|
||||
"--card-foreground": "#e4e4e4",
|
||||
"--popover": "#0e0e0e",
|
||||
"--popover-foreground": "#e4e4e4",
|
||||
"--primary": "#ffffff",
|
||||
"--primary-foreground": "#070707",
|
||||
"--secondary": "#161616",
|
||||
"--secondary-foreground": "#e4e4e4",
|
||||
"--muted": "#161616",
|
||||
"--muted-foreground": "#a0a0a0",
|
||||
"--accent": "#1f1f1f",
|
||||
"--accent-foreground": "#ffffff",
|
||||
"--destructive": "#ec6a5e",
|
||||
"--destructive-foreground": "#070707",
|
||||
"--success": "#61c554",
|
||||
"--success-foreground": "#070707",
|
||||
"--warning": "#f4be4f",
|
||||
"--warning-foreground": "#070707",
|
||||
"--border": "rgba(255,255,255,0.06)",
|
||||
"--chart-1": "#a0a0a0",
|
||||
"--chart-2": "#6b6b6b",
|
||||
"--chart-3": "#444444",
|
||||
"--chart-4": "#e4e4e4",
|
||||
"--chart-5": "#ffffff",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tokyo-night",
|
||||
name: "Tokyo Night",
|
||||
@@ -197,38 +229,45 @@ export const THEMES: Theme[] = [
|
||||
{
|
||||
id: "ayu-light",
|
||||
name: "Ayu Light",
|
||||
// Source: ayu-theme/ayu-colors light.yaml. Primary uses the iconic
|
||||
// Ayu orange instead of blue — that's the colour the theme is known for.
|
||||
colors: {
|
||||
"--background": "#fafafa",
|
||||
"--foreground": "#5c6773",
|
||||
"--card": "#ffffff",
|
||||
"--card-foreground": "#5c6773",
|
||||
"--background": "#f8f9fa",
|
||||
"--foreground": "#5c6166",
|
||||
"--card": "#fcfcfc",
|
||||
"--card-foreground": "#5c6166",
|
||||
"--popover": "#ffffff",
|
||||
"--popover-foreground": "#5c6773",
|
||||
"--primary": "#399ee6",
|
||||
"--primary-foreground": "#fafafa",
|
||||
"--secondary": "#fa8d3e",
|
||||
"--secondary-foreground": "#fafafa",
|
||||
"--muted": "#f0f0f0",
|
||||
"--muted-foreground": "#828c99",
|
||||
"--popover-foreground": "#5c6166",
|
||||
"--primary": "#f29718",
|
||||
"--primary-foreground": "#ffffff",
|
||||
"--secondary": "#399ee6",
|
||||
"--secondary-foreground": "#ffffff",
|
||||
"--muted": "#ebeef0",
|
||||
"--muted-foreground": "#828e9f",
|
||||
"--accent": "#a37acc",
|
||||
"--accent-foreground": "#fafafa",
|
||||
"--destructive": "#f07178",
|
||||
"--destructive-foreground": "#fafafa",
|
||||
"--accent-foreground": "#ffffff",
|
||||
"--destructive": "#e65050",
|
||||
"--destructive-foreground": "#ffffff",
|
||||
"--success": "#86b300",
|
||||
"--success-foreground": "#fafafa",
|
||||
"--warning": "#fa8d3e",
|
||||
"--warning-foreground": "#fafafa",
|
||||
"--border": "#e7eaed",
|
||||
"--chart-1": "#399ee6",
|
||||
"--success-foreground": "#ffffff",
|
||||
"--warning": "#fa8532",
|
||||
"--warning-foreground": "#ffffff",
|
||||
"--border": "#c8d0d6",
|
||||
"--chart-1": "#f29718",
|
||||
"--chart-2": "#86b300",
|
||||
"--chart-3": "#a37acc",
|
||||
"--chart-4": "#fa8d3e",
|
||||
"--chart-5": "#f07178",
|
||||
"--chart-4": "#399ee6",
|
||||
"--chart-5": "#4cbf99",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "catppuccin-latte",
|
||||
name: "Catppuccin Latte",
|
||||
// Source: github.com/catppuccin/palette/blob/main/palette.json
|
||||
// Primary uses mauve (purple) — the colour Catppuccin is most known
|
||||
// for — instead of blue, to differentiate from the many blue themes.
|
||||
// Frappé and Macchiato variants intentionally omitted; they're tonal
|
||||
// mid-points between Latte and Mocha and added little variety.
|
||||
colors: {
|
||||
"--background": "#eff1f5",
|
||||
"--foreground": "#4c4f69",
|
||||
@@ -236,13 +275,13 @@ export const THEMES: Theme[] = [
|
||||
"--card-foreground": "#4c4f69",
|
||||
"--popover": "#ccd0da",
|
||||
"--popover-foreground": "#4c4f69",
|
||||
"--primary": "#1e66f5",
|
||||
"--primary": "#8839ef",
|
||||
"--primary-foreground": "#eff1f5",
|
||||
"--secondary": "#04a5e5",
|
||||
"--secondary": "#1e66f5",
|
||||
"--secondary-foreground": "#eff1f5",
|
||||
"--muted": "#bcc0cc",
|
||||
"--muted-foreground": "#5c5f77",
|
||||
"--accent": "#8839ef",
|
||||
"--muted-foreground": "#6c6f85",
|
||||
"--accent": "#ea76cb",
|
||||
"--accent-foreground": "#eff1f5",
|
||||
"--destructive": "#d20f39",
|
||||
"--destructive-foreground": "#eff1f5",
|
||||
@@ -251,80 +290,18 @@ export const THEMES: Theme[] = [
|
||||
"--warning": "#df8e1d",
|
||||
"--warning-foreground": "#eff1f5",
|
||||
"--border": "#9ca0b0",
|
||||
"--chart-1": "#1e66f5",
|
||||
"--chart-1": "#8839ef",
|
||||
"--chart-2": "#40a02b",
|
||||
"--chart-3": "#8839ef",
|
||||
"--chart-3": "#ea76cb",
|
||||
"--chart-4": "#04a5e5",
|
||||
"--chart-5": "#df8e1d",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "catppuccin-frappe",
|
||||
name: "Catppuccin Frappe",
|
||||
colors: {
|
||||
"--background": "#303446",
|
||||
"--foreground": "#c6d0f5",
|
||||
"--card": "#414559",
|
||||
"--card-foreground": "#c6d0f5",
|
||||
"--popover": "#414559",
|
||||
"--popover-foreground": "#c6d0f5",
|
||||
"--primary": "#8caaee",
|
||||
"--primary-foreground": "#303446",
|
||||
"--secondary": "#99d1db",
|
||||
"--secondary-foreground": "#303446",
|
||||
"--muted": "#51576d",
|
||||
"--muted-foreground": "#b5bfe2",
|
||||
"--accent": "#ca9ee6",
|
||||
"--accent-foreground": "#303446",
|
||||
"--destructive": "#e78284",
|
||||
"--destructive-foreground": "#303446",
|
||||
"--success": "#a6d189",
|
||||
"--success-foreground": "#303446",
|
||||
"--warning": "#e5c890",
|
||||
"--warning-foreground": "#303446",
|
||||
"--border": "#737994",
|
||||
"--chart-1": "#8caaee",
|
||||
"--chart-2": "#a6d189",
|
||||
"--chart-3": "#ca9ee6",
|
||||
"--chart-4": "#99d1db",
|
||||
"--chart-5": "#e5c890",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "catppuccin-macchiato",
|
||||
name: "Catppuccin Macchiato",
|
||||
colors: {
|
||||
"--background": "#24273a",
|
||||
"--foreground": "#cad3f5",
|
||||
"--card": "#363a4f",
|
||||
"--card-foreground": "#cad3f5",
|
||||
"--popover": "#363a4f",
|
||||
"--popover-foreground": "#cad3f5",
|
||||
"--primary": "#8aadf4",
|
||||
"--primary-foreground": "#24273a",
|
||||
"--secondary": "#91d7e3",
|
||||
"--secondary-foreground": "#24273a",
|
||||
"--muted": "#494d64",
|
||||
"--muted-foreground": "#b8c0e0",
|
||||
"--accent": "#c6a0f6",
|
||||
"--accent-foreground": "#24273a",
|
||||
"--destructive": "#ed8796",
|
||||
"--destructive-foreground": "#24273a",
|
||||
"--success": "#a6da95",
|
||||
"--success-foreground": "#24273a",
|
||||
"--warning": "#eed49f",
|
||||
"--warning-foreground": "#24273a",
|
||||
"--border": "#6e738d",
|
||||
"--chart-1": "#8aadf4",
|
||||
"--chart-2": "#a6da95",
|
||||
"--chart-3": "#c6a0f6",
|
||||
"--chart-4": "#91d7e3",
|
||||
"--chart-5": "#eed49f",
|
||||
"--chart-5": "#fe640b",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "catppuccin-mocha",
|
||||
name: "Catppuccin Mocha",
|
||||
// Source: github.com/catppuccin/palette/blob/main/palette.json
|
||||
// Primary uses mauve (purple) — Catppuccin's signature colour.
|
||||
colors: {
|
||||
"--background": "#1e1e2e",
|
||||
"--foreground": "#cdd6f4",
|
||||
@@ -332,13 +309,13 @@ export const THEMES: Theme[] = [
|
||||
"--card-foreground": "#cdd6f4",
|
||||
"--popover": "#313244",
|
||||
"--popover-foreground": "#cdd6f4",
|
||||
"--primary": "#89b4fa",
|
||||
"--primary": "#cba6f7",
|
||||
"--primary-foreground": "#1e1e2e",
|
||||
"--secondary": "#89dceb",
|
||||
"--secondary": "#89b4fa",
|
||||
"--secondary-foreground": "#1e1e2e",
|
||||
"--muted": "#45475a",
|
||||
"--muted-foreground": "#bac2de",
|
||||
"--accent": "#cba6f7",
|
||||
"--muted-foreground": "#a6adc8",
|
||||
"--accent": "#f5c2e7",
|
||||
"--accent-foreground": "#1e1e2e",
|
||||
"--destructive": "#f38ba8",
|
||||
"--destructive-foreground": "#1e1e2e",
|
||||
@@ -347,11 +324,381 @@ export const THEMES: Theme[] = [
|
||||
"--warning": "#f9e2af",
|
||||
"--warning-foreground": "#1e1e2e",
|
||||
"--border": "#585b70",
|
||||
"--chart-1": "#89b4fa",
|
||||
"--chart-1": "#cba6f7",
|
||||
"--chart-2": "#a6e3a1",
|
||||
"--chart-3": "#cba6f7",
|
||||
"--chart-3": "#f5c2e7",
|
||||
"--chart-4": "#89dceb",
|
||||
"--chart-5": "#f9e2af",
|
||||
"--chart-5": "#fab387",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "nord",
|
||||
name: "Nord",
|
||||
// Source: nordtheme.com/docs/colors-and-palettes (Polar Night / Snow Storm / Frost / Aurora)
|
||||
colors: {
|
||||
"--background": "#2e3440",
|
||||
"--foreground": "#d8dee9",
|
||||
"--card": "#3b4252",
|
||||
"--card-foreground": "#d8dee9",
|
||||
"--popover": "#3b4252",
|
||||
"--popover-foreground": "#d8dee9",
|
||||
"--primary": "#81a1c1",
|
||||
"--primary-foreground": "#2e3440",
|
||||
"--secondary": "#88c0d0",
|
||||
"--secondary-foreground": "#2e3440",
|
||||
"--muted": "#434c5e",
|
||||
"--muted-foreground": "#d8dee9",
|
||||
"--accent": "#b48ead",
|
||||
"--accent-foreground": "#2e3440",
|
||||
"--destructive": "#bf616a",
|
||||
"--destructive-foreground": "#eceff4",
|
||||
"--success": "#a3be8c",
|
||||
"--success-foreground": "#2e3440",
|
||||
"--warning": "#ebcb8b",
|
||||
"--warning-foreground": "#2e3440",
|
||||
"--border": "#4c566a",
|
||||
"--chart-1": "#81a1c1",
|
||||
"--chart-2": "#a3be8c",
|
||||
"--chart-3": "#b48ead",
|
||||
"--chart-4": "#88c0d0",
|
||||
"--chart-5": "#d08770",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gruvbox-dark",
|
||||
name: "Gruvbox Dark",
|
||||
// Source: github.com/morhetz/gruvbox medium-contrast dark palette.
|
||||
// Primary uses the iconic Gruvbox orange instead of blue.
|
||||
colors: {
|
||||
"--background": "#282828",
|
||||
"--foreground": "#ebdbb2",
|
||||
"--card": "#3c3836",
|
||||
"--card-foreground": "#ebdbb2",
|
||||
"--popover": "#3c3836",
|
||||
"--popover-foreground": "#ebdbb2",
|
||||
"--primary": "#fe8019",
|
||||
"--primary-foreground": "#282828",
|
||||
"--secondary": "#83a598",
|
||||
"--secondary-foreground": "#282828",
|
||||
"--muted": "#504945",
|
||||
"--muted-foreground": "#a89984",
|
||||
"--accent": "#d3869b",
|
||||
"--accent-foreground": "#282828",
|
||||
"--destructive": "#fb4934",
|
||||
"--destructive-foreground": "#282828",
|
||||
"--success": "#b8bb26",
|
||||
"--success-foreground": "#282828",
|
||||
"--warning": "#fabd2f",
|
||||
"--warning-foreground": "#282828",
|
||||
"--border": "#665c54",
|
||||
"--chart-1": "#fe8019",
|
||||
"--chart-2": "#b8bb26",
|
||||
"--chart-3": "#d3869b",
|
||||
"--chart-4": "#83a598",
|
||||
"--chart-5": "#8ec07c",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gruvbox-light",
|
||||
name: "Gruvbox Light",
|
||||
// Source: github.com/morhetz/gruvbox medium-contrast light palette.
|
||||
// Primary uses the iconic Gruvbox orange instead of blue.
|
||||
colors: {
|
||||
"--background": "#fbf1c7",
|
||||
"--foreground": "#3c3836",
|
||||
"--card": "#ebdbb2",
|
||||
"--card-foreground": "#3c3836",
|
||||
"--popover": "#ebdbb2",
|
||||
"--popover-foreground": "#3c3836",
|
||||
"--primary": "#af3a03",
|
||||
"--primary-foreground": "#fbf1c7",
|
||||
"--secondary": "#076678",
|
||||
"--secondary-foreground": "#fbf1c7",
|
||||
"--muted": "#d5c4a1",
|
||||
"--muted-foreground": "#7c6f64",
|
||||
"--accent": "#8f3f71",
|
||||
"--accent-foreground": "#fbf1c7",
|
||||
"--destructive": "#9d0006",
|
||||
"--destructive-foreground": "#fbf1c7",
|
||||
"--success": "#79740e",
|
||||
"--success-foreground": "#fbf1c7",
|
||||
"--warning": "#b57614",
|
||||
"--warning-foreground": "#fbf1c7",
|
||||
"--border": "#a89984",
|
||||
"--chart-1": "#af3a03",
|
||||
"--chart-2": "#79740e",
|
||||
"--chart-3": "#8f3f71",
|
||||
"--chart-4": "#076678",
|
||||
"--chart-5": "#427b58",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "solarized-dark",
|
||||
name: "Solarized Dark",
|
||||
// Source: ethanschoonover.com/solarized — base03 / base02 / base01 / base00 / base0 / base1
|
||||
colors: {
|
||||
"--background": "#002b36",
|
||||
"--foreground": "#839496",
|
||||
"--card": "#073642",
|
||||
"--card-foreground": "#839496",
|
||||
"--popover": "#073642",
|
||||
"--popover-foreground": "#839496",
|
||||
"--primary": "#268bd2",
|
||||
"--primary-foreground": "#002b36",
|
||||
"--secondary": "#2aa198",
|
||||
"--secondary-foreground": "#002b36",
|
||||
"--muted": "#073642",
|
||||
"--muted-foreground": "#93a1a1",
|
||||
"--accent": "#6c71c4",
|
||||
"--accent-foreground": "#fdf6e3",
|
||||
"--destructive": "#dc322f",
|
||||
"--destructive-foreground": "#fdf6e3",
|
||||
"--success": "#859900",
|
||||
"--success-foreground": "#002b36",
|
||||
"--warning": "#b58900",
|
||||
"--warning-foreground": "#002b36",
|
||||
"--border": "#586e75",
|
||||
"--chart-1": "#268bd2",
|
||||
"--chart-2": "#859900",
|
||||
"--chart-3": "#6c71c4",
|
||||
"--chart-4": "#2aa198",
|
||||
"--chart-5": "#cb4b16",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "solarized-light",
|
||||
name: "Solarized Light",
|
||||
// Source: ethanschoonover.com/solarized — same accents, inverted base scale
|
||||
colors: {
|
||||
"--background": "#fdf6e3",
|
||||
"--foreground": "#657b83",
|
||||
"--card": "#eee8d5",
|
||||
"--card-foreground": "#657b83",
|
||||
"--popover": "#eee8d5",
|
||||
"--popover-foreground": "#657b83",
|
||||
"--primary": "#268bd2",
|
||||
"--primary-foreground": "#fdf6e3",
|
||||
"--secondary": "#2aa198",
|
||||
"--secondary-foreground": "#fdf6e3",
|
||||
"--muted": "#eee8d5",
|
||||
"--muted-foreground": "#93a1a1",
|
||||
"--accent": "#6c71c4",
|
||||
"--accent-foreground": "#fdf6e3",
|
||||
"--destructive": "#dc322f",
|
||||
"--destructive-foreground": "#fdf6e3",
|
||||
"--success": "#859900",
|
||||
"--success-foreground": "#fdf6e3",
|
||||
"--warning": "#b58900",
|
||||
"--warning-foreground": "#fdf6e3",
|
||||
"--border": "#cdc7b3",
|
||||
"--chart-1": "#268bd2",
|
||||
"--chart-2": "#859900",
|
||||
"--chart-3": "#6c71c4",
|
||||
"--chart-4": "#2aa198",
|
||||
"--chart-5": "#cb4b16",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "one-dark",
|
||||
name: "One Dark",
|
||||
// Source: github.com/atom/atom one-dark-syntax/styles/colors.less (mono-1, hue-1..6)
|
||||
colors: {
|
||||
"--background": "#282c34",
|
||||
"--foreground": "#abb2bf",
|
||||
"--card": "#21252b",
|
||||
"--card-foreground": "#abb2bf",
|
||||
"--popover": "#21252b",
|
||||
"--popover-foreground": "#abb2bf",
|
||||
"--primary": "#61afef",
|
||||
"--primary-foreground": "#282c34",
|
||||
"--secondary": "#56b6c2",
|
||||
"--secondary-foreground": "#282c34",
|
||||
"--muted": "#3e4451",
|
||||
"--muted-foreground": "#7d8590",
|
||||
"--accent": "#c678dd",
|
||||
"--accent-foreground": "#282c34",
|
||||
"--destructive": "#e06c75",
|
||||
"--destructive-foreground": "#282c34",
|
||||
"--success": "#98c379",
|
||||
"--success-foreground": "#282c34",
|
||||
"--warning": "#e5c07b",
|
||||
"--warning-foreground": "#282c34",
|
||||
"--border": "#3e4451",
|
||||
"--chart-1": "#61afef",
|
||||
"--chart-2": "#98c379",
|
||||
"--chart-3": "#c678dd",
|
||||
"--chart-4": "#56b6c2",
|
||||
"--chart-5": "#d19a66",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "monokai-pro",
|
||||
name: "Monokai Pro",
|
||||
// Source: classic Monokai filter (monokai-pro.nvim palette/classic.lua).
|
||||
// Primary uses Monokai's signature green instead of cyan.
|
||||
colors: {
|
||||
"--background": "#272822",
|
||||
"--foreground": "#fdfff1",
|
||||
"--card": "#1d1e19",
|
||||
"--card-foreground": "#fdfff1",
|
||||
"--popover": "#1d1e19",
|
||||
"--popover-foreground": "#fdfff1",
|
||||
"--primary": "#a6e22e",
|
||||
"--primary-foreground": "#272822",
|
||||
"--secondary": "#66d9ef",
|
||||
"--secondary-foreground": "#272822",
|
||||
"--muted": "#3b3c35",
|
||||
"--muted-foreground": "#919288",
|
||||
"--accent": "#ae81ff",
|
||||
"--accent-foreground": "#272822",
|
||||
"--destructive": "#f92672",
|
||||
"--destructive-foreground": "#fdfff1",
|
||||
"--success": "#a6e22e",
|
||||
"--success-foreground": "#272822",
|
||||
"--warning": "#e6db74",
|
||||
"--warning-foreground": "#272822",
|
||||
"--border": "#57584f",
|
||||
"--chart-1": "#a6e22e",
|
||||
"--chart-2": "#66d9ef",
|
||||
"--chart-3": "#ae81ff",
|
||||
"--chart-4": "#e6db74",
|
||||
"--chart-5": "#fd971f",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rose-pine",
|
||||
name: "Rosé Pine",
|
||||
// Source: github.com/rose-pine/palette/blob/main/palette.json.
|
||||
// Primary uses iris (purple) — the iconic Rosé Pine accent — and
|
||||
// success uses pine. Destructive stays love (pink), which is correct
|
||||
// for the palette's red role.
|
||||
colors: {
|
||||
"--background": "#191724",
|
||||
"--foreground": "#e0def4",
|
||||
"--card": "#1f1d2e",
|
||||
"--card-foreground": "#e0def4",
|
||||
"--popover": "#1f1d2e",
|
||||
"--popover-foreground": "#e0def4",
|
||||
"--primary": "#c4a7e7",
|
||||
"--primary-foreground": "#191724",
|
||||
"--secondary": "#9ccfd8",
|
||||
"--secondary-foreground": "#191724",
|
||||
"--muted": "#26233a",
|
||||
"--muted-foreground": "#908caa",
|
||||
"--accent": "#ebbcba",
|
||||
"--accent-foreground": "#191724",
|
||||
"--destructive": "#eb6f92",
|
||||
"--destructive-foreground": "#191724",
|
||||
"--success": "#31748f",
|
||||
"--success-foreground": "#e0def4",
|
||||
"--warning": "#f6c177",
|
||||
"--warning-foreground": "#191724",
|
||||
"--border": "#403d52",
|
||||
"--chart-1": "#c4a7e7",
|
||||
"--chart-2": "#9ccfd8",
|
||||
"--chart-3": "#ebbcba",
|
||||
"--chart-4": "#eb6f92",
|
||||
"--chart-5": "#f6c177",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rose-pine-dawn",
|
||||
name: "Rosé Pine Dawn",
|
||||
// Source: github.com/rose-pine/palette/blob/main/palette.json (dawn variant).
|
||||
// Primary uses iris (purple) for parity with the dark variant.
|
||||
colors: {
|
||||
"--background": "#faf4ed",
|
||||
"--foreground": "#575279",
|
||||
"--card": "#fffaf3",
|
||||
"--card-foreground": "#575279",
|
||||
"--popover": "#fffaf3",
|
||||
"--popover-foreground": "#575279",
|
||||
"--primary": "#907aa9",
|
||||
"--primary-foreground": "#faf4ed",
|
||||
"--secondary": "#56949f",
|
||||
"--secondary-foreground": "#faf4ed",
|
||||
"--muted": "#f2e9e1",
|
||||
"--muted-foreground": "#797593",
|
||||
"--accent": "#d7827e",
|
||||
"--accent-foreground": "#faf4ed",
|
||||
"--destructive": "#b4637a",
|
||||
"--destructive-foreground": "#faf4ed",
|
||||
"--success": "#286983",
|
||||
"--success-foreground": "#faf4ed",
|
||||
"--warning": "#ea9d34",
|
||||
"--warning-foreground": "#faf4ed",
|
||||
"--border": "#cecacd",
|
||||
"--chart-1": "#907aa9",
|
||||
"--chart-2": "#56949f",
|
||||
"--chart-3": "#d7827e",
|
||||
"--chart-4": "#b4637a",
|
||||
"--chart-5": "#ea9d34",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "github-dark",
|
||||
name: "GitHub Dark",
|
||||
// Source: github.com/primer/primitives base color tokens (dark default)
|
||||
colors: {
|
||||
"--background": "#0d1117",
|
||||
"--foreground": "#f0f6fc",
|
||||
"--card": "#151b23",
|
||||
"--card-foreground": "#f0f6fc",
|
||||
"--popover": "#151b23",
|
||||
"--popover-foreground": "#f0f6fc",
|
||||
"--primary": "#1f6feb",
|
||||
"--primary-foreground": "#f0f6fc",
|
||||
"--secondary": "#58a6ff",
|
||||
"--secondary-foreground": "#0d1117",
|
||||
"--muted": "#212830",
|
||||
"--muted-foreground": "#9198a1",
|
||||
"--accent": "#8957e5",
|
||||
"--accent-foreground": "#f0f6fc",
|
||||
"--destructive": "#da3633",
|
||||
"--destructive-foreground": "#f0f6fc",
|
||||
"--success": "#238636",
|
||||
"--success-foreground": "#f0f6fc",
|
||||
"--warning": "#d29922",
|
||||
"--warning-foreground": "#0d1117",
|
||||
"--border": "#3d444d",
|
||||
"--chart-1": "#1f6feb",
|
||||
"--chart-2": "#238636",
|
||||
"--chart-3": "#8957e5",
|
||||
"--chart-4": "#58a6ff",
|
||||
"--chart-5": "#db6d28",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "github-light",
|
||||
name: "GitHub Light",
|
||||
// Source: github.com/primer/primitives base color tokens (light default)
|
||||
colors: {
|
||||
"--background": "#ffffff",
|
||||
"--foreground": "#25292e",
|
||||
"--card": "#f6f8fa",
|
||||
"--card-foreground": "#25292e",
|
||||
"--popover": "#f6f8fa",
|
||||
"--popover-foreground": "#25292e",
|
||||
"--primary": "#0969da",
|
||||
"--primary-foreground": "#ffffff",
|
||||
"--secondary": "#54aeff",
|
||||
"--secondary-foreground": "#ffffff",
|
||||
"--muted": "#eff2f5",
|
||||
"--muted-foreground": "#59636e",
|
||||
"--accent": "#8250df",
|
||||
"--accent-foreground": "#ffffff",
|
||||
"--destructive": "#cf222e",
|
||||
"--destructive-foreground": "#ffffff",
|
||||
"--success": "#1a7f37",
|
||||
"--success-foreground": "#ffffff",
|
||||
"--warning": "#bf8700",
|
||||
"--warning-foreground": "#ffffff",
|
||||
"--border": "#d1d9e0",
|
||||
"--chart-1": "#0969da",
|
||||
"--chart-2": "#1a7f37",
|
||||
"--chart-3": "#8250df",
|
||||
"--chart-4": "#54aeff",
|
||||
"--chart-5": "#bc4c00",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
+54
-23
@@ -97,29 +97,30 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--success: oklch(0.7 0.2 145);
|
||||
--success-foreground: oklch(0.141 0.005 285.823);
|
||||
--warning: oklch(0.8 0.15 75);
|
||||
--warning-foreground: oklch(0.141 0.005 285.823);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
/* Donut Mono — redesign monochrome palette */
|
||||
--background: #070707;
|
||||
--foreground: #ffffff;
|
||||
--card: #0e0e0e;
|
||||
--card-foreground: #e4e4e4;
|
||||
--popover: #0e0e0e;
|
||||
--popover-foreground: #e4e4e4;
|
||||
--primary: #ffffff;
|
||||
--primary-foreground: #070707;
|
||||
--secondary: #161616;
|
||||
--secondary-foreground: #e4e4e4;
|
||||
--muted: #161616;
|
||||
--muted-foreground: #a0a0a0;
|
||||
--accent: #1f1f1f;
|
||||
--accent-foreground: #ffffff;
|
||||
--destructive: #ec6a5e;
|
||||
--destructive-foreground: #070707;
|
||||
--success: #61c554;
|
||||
--success-foreground: #070707;
|
||||
--warning: #f4be4f;
|
||||
--warning-foreground: #070707;
|
||||
--border: rgba(255, 255, 255, 0.06);
|
||||
--input: rgba(255, 255, 255, 0.1);
|
||||
--ring: #6b6b6b;
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
@@ -156,6 +157,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Scroll-fade utility: a vertical mask whose top/bottom 16px fade to
|
||||
transparent ONLY when the matching direction is scrollable. The component
|
||||
sets `data-fade-top` / `data-fade-bottom` attributes on its container as
|
||||
the user scrolls; each attribute toggles its own end of the mask via a
|
||||
CSS variable, so the two edges are independent. */
|
||||
.scroll-fade {
|
||||
--top-mask: black;
|
||||
--bottom-mask: black;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--top-mask),
|
||||
black 16px,
|
||||
black calc(100% - 16px),
|
||||
var(--bottom-mask)
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--top-mask),
|
||||
black 16px,
|
||||
black calc(100% - 16px),
|
||||
var(--bottom-mask)
|
||||
);
|
||||
}
|
||||
.scroll-fade[data-fade-top="true"] {
|
||||
--top-mask: transparent;
|
||||
}
|
||||
.scroll-fade[data-fade-bottom="true"] {
|
||||
--bottom-mask: transparent;
|
||||
}
|
||||
|
||||
/* Ensure Sonner toasts appear above all dialogs and remain interactive */
|
||||
.toaster,
|
||||
[data-sonner-toaster] {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user